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::dose_reminders::{DoseReminders, Elsewhere, Others, Tried};
20use sioul_core::doses::{Answer, Answered, DoseRecords, Given, State};
21use sioul_core::health::{ChatLimit, Doubt, ErrandKind, Health, HealthState, Medicine, Movement, Named, Peer, Prescription, Problem, Schedule, Take, TakeProblem};
22use sioul_core::i18n::Translator;
23use sioul_core::needs::{DayEdit, DayProblem, Days, Kept, Needs};
24use sioul_core::tasks::TaskEdit;
25use std::sync::Arc;
26
27fn load() -> Health {
28    Health::load(&Health::default_path())
29}
30
31/// The health file read, changed by `change` and written back, all under its
32/// lock, which the sharing takes too: a change is set over the medicines as
33/// the file holds them then (`Health::change`), never over a copy read earlier.
34fn change_health<R>(change: impl FnOnce(&mut Health) -> Result<R, String>) -> Result<R, String> {
35    let out = Health::change(&Health::default_path(), change)?;
36    // A medicine changed: a phone's alarms follow (`alarms`).
37    crate::alarms::schedule();
38    Ok(out)
39}
40
41/// "Levothyroxine and Magnesium", "A, B and C".
42fn listed(items: &[String]) -> String {
43    match items {
44        [] => String::new(),
45        [one] => one.clone(),
46        [rest @ .., last] => format!("{} {} {last}", rest.join(", "), tr().text("word-and", None)),
47    }
48}
49
50/// "8:00" → "08:00", as the day's list writes its times; what does not read stays as written.
51fn hhmm(text: &str) -> String {
52    let parsed = text.trim().split_once(':').and_then(|(h, m)| Some((h.trim().parse::<u8>().ok()?, m.trim().parse::<u8>().ok()?)));
53    match parsed {
54        Some((h, m)) if h < 24 && m < 60 => format!("{h:02}:{m:02}"),
55        _ => text.trim().to_string(),
56    }
57}
58
59/// When a medicine is taken: its times each day, "08:00 · 20:00"; every few
60/// days or hours, in words ("every other day at 08:00, from Sunday 4 October").
61fn words(schedule: &Schedule) -> String {
62    match schedule {
63        Schedule::Day { times, .. } => {
64            let mut times: Vec<String> = times.iter().map(|t| hhmm(t)).collect();
65            times.sort();
66            times.dedup();
67            times.join(" · ")
68        }
69        Schedule::Days { days, time, from } => {
70            let mut args = sioul_core::i18n::args();
71            args.set("days", *days);
72            args.set("time", time.clone());
73            args.set("from", tr().day(*from));
74            tr().text("health-every-days", Some(&args))
75        }
76        Schedule::Hours { hours, from } => {
77            // Said by its next dose: each dose taken sets the next.
78            let now = Zoned::now();
79            let next = schedule.doses(&now, &now.checked_add(Span::new().hours(i64::from(*hours))).unwrap_or_else(|_| now.clone())).into_iter().next();
80            let next = next.map_or(*from, |z| z.timestamp().as_second());
81            let next = Timestamp::from_second(next).map(|t| tr().date(&t.to_zoned(jiff::tz::TimeZone::system()), false)).unwrap_or_default();
82            let mut args = sioul_core::i18n::args();
83            args.set("hours", *hours);
84            args.set("next", next);
85            tr().text("health-every-hours", Some(&args))
86        }
87    }
88}
89
90#[derive(Serialize)]
91struct DoseRow {
92    key: String,
93    time: String,
94    name: String,
95    dose: String,
96    /// "12:04" when marked taken; "" otherwise.
97    taken: String,
98    past: bool,
99    /// Past its time by more than half an hour, not marked: marked now, it
100    /// asks when it was taken (`DoseTaken.qml`).
101    late: bool,
102    /// Not marked here, but whether it was taken is not known here: why, in
103    /// a sentence; "" when it is known (docs/health.md, "Knowing"). Answers
104    /// that differ on your devices: both, said (`choose`).
105    doubt: String,
106    /// A "This device is off" for each device the doubt names (`device_off`).
107    doubt_off: Vec<OffButton>,
108    /// Answers that differ, said in `doubt`: "Taken" and "Not taken" settle it (`choose`).
109    choose: bool,
110    /// For a dose due earlier and answered nowhere (`missed_rows`), the
111    /// question it is asked under, shown above the first of its kind: due
112    /// while Sioul was closed, or due while it ran but its reminder could not
113    /// be shown. "" for today's doses on the Porch.
114    question: String,
115}
116
117/// "This device is off", under a doubt naming it: the device's id, the button's words.
118#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
119struct OffButton {
120    id: String,
121    label: String,
122}
123
124/// A medicine as the page lists it, and as its form reads it.
125#[derive(Serialize)]
126struct MedicineRow {
127    #[serde(flatten)]
128    medicine: Medicine,
129    /// Its times, "08:00 · 20:00"; each take with its amount when they
130    /// differ, "08:00 · 1 tablet, 20:00 · 2 tablets"; every few days or
131    /// hours, in words.
132    when: String,
133    /// What the page says beside its name: its dose; "" when each take says its own.
134    amount: String,
135    /// Its name, generic name and strength: "Thyrolan — levothyroxine 75 µg" (`Medicine::precise`).
136    precise: String,
137    /// All of it in a line, for a prescription's entry: "Thyrolan — levothyroxine 75 µg · 07:30 · 1 tablet".
138    line: String,
139    /// Its form's dose: the amount most takes have (`Medicine::usual`).
140    usual: String,
141    /// Its takes at set times each day, in time order, for its form: each
142    /// take's own amount only when it differs from `usual` ("" otherwise).
143    takes: Vec<Take>,
144    /// "until Monday 26 October"; "" for as long as it goes.
145    ends: String,
146    /// Paused, or past its last day: listed all the same, quieter.
147    quiet: bool,
148}
149
150/// What a medicine says of its takes: each with its amount when they differ
151/// ("08:00 · 1 tablet, 20:00 · 2 tablets"), else its times, or its schedule in words.
152fn when_of(medicine: &Medicine) -> String {
153    match &medicine.schedule {
154        Schedule::Day { amounts, .. } if !amounts.is_empty() => medicine.takes_line(),
155        schedule => words(schedule),
156    }
157}
158
159/// A medicine as the page and its forms read it.
160fn medicine_row(m: &Medicine, today: Date) -> MedicineRow {
161    let usual = m.usual();
162    let each = matches!(&m.schedule, Schedule::Day { amounts, .. } if !amounts.is_empty());
163    let when = when_of(m);
164    let amount = if each { String::new() } else { m.dose.clone() };
165    MedicineRow {
166        precise: m.precise(),
167        line: [m.precise(), when.clone(), amount.clone()].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" · "),
168        when,
169        amount,
170        takes: m.takes().into_iter().map(|t| Take { amount: if t.amount == usual { String::new() } else { t.amount }, time: t.time }).collect(),
171        usual,
172        ends: m.until.map(|day| say("health-until", &[("day", tr().day_in(day, today))])).unwrap_or_default(),
173        quiet: m.paused || m.until.is_some_and(|day| day < today),
174        medicine: m.clone(),
175    }
176}
177
178/// A prescription as the page lists it, and as its form reads it.
179#[derive(Serialize)]
180struct PrescriptionRow {
181    #[serde(flatten)]
182    prescription: Prescription,
183    /// What comes, in words, the soonest first: "pharmacy from Tuesday 27
184    /// October", "renew by Thursday 4 February 2027" (its last valid day).
185    next: Vec<String>,
186    /// The medicines that come with it, unless its title names them all:
187    /// "for Levothyroxine and Magnesium"; "" when none or named, or when
188    /// `lines` says them.
189    covers: String,
190    /// Its medicines precisely, a line each, when one has a generic name or
191    /// a strength: "Thyrolan — levothyroxine 75 µg · 07:30 · 1 tablet".
192    lines: Vec<String>,
193    /// Fetched at the pharmacy today: its button says so.
194    fetched_today: bool,
195    /// The medicines tied to it, in the order they were added: its form's rows.
196    medicines: Vec<MedicineRow>,
197}
198
199/// The medicines, in the order they were added (a row keeps its place).
200fn medicine_rows(health: &Health, today: Date) -> Vec<MedicineRow> {
201    health.medicines.iter().map(|m| medicine_row(m, today)).collect()
202}
203
204/// What comes for a prescription, on a day.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206enum Coming {
207    /// Its medicines fetched at the pharmacy, from that day.
208    Pharmacy,
209    /// Renewed by that day, its last valid day.
210    RenewBy,
211    /// Valid until that day, now passed: said plainly, never as late.
212    ValidUntil,
213}
214
215/// What comes for a prescription, the soonest first: the next visit to the
216/// pharmacy (a month back at most), and the day to renew it by.
217fn coming(errands: &[sioul_core::health::Errand], p: &Prescription, today: Date) -> Vec<(Coming, Date)> {
218    let month_ago = today.checked_sub(Span::new().days(30)).unwrap_or(today);
219    let mut next: Vec<(Coming, Date)> = errands.iter().filter(|e| e.prescription == p.id && e.kind == ErrandKind::Refill && e.day >= month_ago).map(|e| (Coming::Pharmacy, e.day)).collect();
220    if let Some(until) = p.until {
221        next.push((if until < today { Coming::ValidUntil } else { Coming::RenewBy }, until));
222    }
223    next.sort_by_key(|(_, day)| *day);
224    next
225}
226
227/// The medicines tied to a prescription (`Medicine::prescription`), in the order they were added.
228fn tied<'a>(health: &'a Health, p: &'a Prescription) -> impl Iterator<Item = &'a Medicine> {
229    health.medicines.iter().filter(|m| m.prescription.as_deref() == Some(p.id.as_str()))
230}
231
232/// The medicines tied to a prescription, unless its title already names
233/// them all ("Levothyroxine 75 µg" for Levothyroxine).
234fn covered(health: &Health, p: &Prescription) -> Vec<String> {
235    let named: Vec<String> = tied(health, p).map(|m| m.name.clone()).collect();
236    let title = p.title.to_lowercase();
237    if named.iter().all(|name| title.contains(&name.to_lowercase())) { Vec::new() } else { named }
238}
239
240/// The prescriptions, in the order they were added, as the page lists them.
241fn prescription_rows(health: &Health, today: Date) -> Vec<PrescriptionRow> {
242    let errands = health.errands();
243    health
244        .prescriptions
245        .iter()
246        .map(|p| {
247            let precise = tied(health, p).any(|m| !m.generic.trim().is_empty() || !m.strength.trim().is_empty());
248            let lines = if precise { tied(health, p).map(|m| medicine_row(m, today).line).collect() } else { Vec::new() };
249            let covers = if precise { Vec::new() } else { covered(health, p) };
250            PrescriptionRow {
251                lines,
252                next: coming(&errands, p, today)
253                    .into_iter()
254                    .map(|(what, day)| {
255                        let day = [("day", tr().day_in(day, today))];
256                        match what {
257                            Coming::Pharmacy => say("health-next-refill", &day),
258                            Coming::RenewBy => say("health-renew-by", &day),
259                            Coming::ValidUntil => say("health-valid-until", &day),
260                        }
261                    })
262                    .collect(),
263                covers: if covers.is_empty() { String::new() } else { say("health-covers", &[("names", listed(&covers))]) },
264                fetched_today: p.last_refill == Some(today),
265                medicines: tied(health, p).map(|m| medicine_row(m, today)).collect(),
266                prescription: p.clone(),
267            }
268        })
269        .collect()
270}
271
272/// The page: a week of days, the day shown one of them (`week`), and what
273/// only today says.
274#[derive(Serialize)]
275struct PageView {
276    week: WeekView,
277    /// Doses neither marked nor reminded: due while Sioul ran nowhere, or
278    /// while it ran but could not show their reminder; a question on the past.
279    missed: Vec<DoseRow>,
280    /// Why a dose marked elsewhere may not show here: this computer alone, or
281    /// your other computers not heard from lately; "" when all is known.
282    shared_note: String,
283    /// Reminders come on another computer, the one you are at: said, by its name.
284    reminded_there: String,
285    /// The minutes "Later" moves a block by.
286    later: u32,
287    /// Anything set at all (meals, naps, the night, a medicine): else the page says where to set them.
288    any: bool,
289    /// The medicines and the prescriptions: content of the page, changed there (their forms read them).
290    medicines: Vec<MedicineRow>,
291    prescriptions: Vec<PrescriptionRow>,
292}
293
294/// The page's settings (⚙), what is set once: the pauses, where the errands
295/// go (the usual meals and night: `needs_page`).
296#[derive(Serialize)]
297struct SettingsView {
298    movement: Movement,
299    chats: ChatLimit,
300    /// Where the errands go, and the lists they can go to: {id, name}.
301    errands_list: String,
302    lists: Vec<serde_json::Value>,
303}
304
305/// Monday to Sunday, the hours the timeline shows (minutes from midnight,
306/// whole hours, the same for every day: the lines stay put from day to day).
307#[derive(Serialize)]
308struct WeekView {
309    monday: String,
310    today: String,
311    from_minute: i64,
312    to_minute: i64,
313    days: Vec<DayView>,
314}
315
316/// One day: its list and what its column of the timeline draws.
317#[derive(Serialize)]
318struct DayView {
319    /// "2026-10-07".
320    date: String,
321    /// "Today, Wednesday 7 October", "Tomorrow, …", "Thursday 8 October".
322    title: String,
323    /// "Wed", and its number: a week's column.
324    weekday: String,
325    number: i8,
326    today: bool,
327    /// Before today: shown, never changed.
328    past: bool,
329    /// The list, in time order: the day's meals, naps and night (those taken
330    /// out that day too, to put back), those added that day, its doses.
331    items: Vec<DayItem>,
332    /// What the timeline draws in this day's column, in minutes from its midnight.
333    segments: Vec<Segment>,
334    /// Its events and today's planned steps, faded, for context.
335    context: Vec<ContextItem>,
336    /// Events of the whole day; refills and renewals falling on it.
337    lines: Vec<String>,
338}
339
340/// A row of a day's list: a meal, a nap, the night, or a dose.
341#[derive(Serialize)]
342struct DayItem {
343    /// "meal:1", "nap:0", "sleep", "added-…", or a dose's key.
344    key: String,
345    /// "meal", "nap", "sleep", "dose".
346    kind: &'static str,
347    name: String,
348    /// A dose's amount ("75 µg").
349    dose: String,
350    /// Kept from, to ("12:10", "13:00"): getting it ready, winding down,
351    /// coming back included, as the notices say; a dose: its time.
352    from: String,
353    to: String,
354    /// When it starts proper: eating, the nap, bed.
355    at: String,
356    /// Said under it: "eating from 12:30", "winding down, bed at 23:00".
357    detail: String,
358    /// How that day differs: "changed for this day", "this day only", "moved
359    /// after an event", "no notice that day…", "removed from this day"; "" as usual.
360    note: String,
361    /// Changed that day (its times), added that day, quiet, taken out that day.
362    changed: bool,
363    added: bool,
364    quiet: bool,
365    off: bool,
366    /// Over (or on a past day): shown, never changed.
367    past: bool,
368    /// Its lengths that day, for its form: minutes, getting ready (winding down), coming back.
369    minutes: u32,
370    before: u32,
371    after: u32,
372    /// A dose of today: "12:04" when marked taken; late past half an hour;
373    /// the doubt when another device may know (never "not taken").
374    taken: String,
375    late: bool,
376    doubt: String,
377    /// "This device is off" for each device the doubt names; answers that differ (`DoseRow`).
378    doubt_off: Vec<OffButton>,
379    choose: bool,
380    /// The night: when its alarm rings, the morning it ends ("07:00"; "" when
381    /// none would), and whether "No alarm" was asked for that night (`wake`).
382    alarm: String,
383    alarm_skipped: bool,
384    /// Its alarm not rung yet: "No alarm" can still be asked, even after
385    /// midnight, on the night going on (a past day's row, its menu that item only).
386    alarm_open: bool,
387}
388
389/// A part of the timeline's column: from, to, in minutes from the column's
390/// midnight (a night split at midnight, its morning part the day before's).
391#[derive(Serialize)]
392struct Segment {
393    /// The day it belongs to, and its key: what the list's row is.
394    date: String,
395    key: String,
396    /// "meal", "nap", "sleep", "dose".
397    kind: &'static str,
398    name: String,
399    from_minute: i64,
400    to_minute: i64,
401    /// Its whole span as kept, not cut at the column's midnights (before 0, past
402    /// 1440): a drag knows which of its ends this column holds, and how long it is.
403    start_minute: i64,
404    end_minute: i64,
405    /// Where it starts and ends proper: before and after, lighter (getting ready, winding down, coming back).
406    at_minute: i64,
407    until_minute: i64,
408    quiet: bool,
409    past: bool,
410    /// A dose of today: "taken", "due", "check" (another device may know); "" another day.
411    state: &'static str,
412}
413
414/// An event or a planned step, for context only.
415#[derive(Serialize)]
416struct ContextItem {
417    title: String,
418    /// "event", "task".
419    kind: &'static str,
420    from_minute: i64,
421    to_minute: i64,
422}
423
424/// The list the errands go to: the one chosen, else your usual list, else the
425/// first list on a server (so the phone has them), else one on this computer.
426fn errands_list(health: &Health) -> Option<String> {
427    let lists: Vec<sioul_core::vdir::Collection> = sioul_core::tasks::lists().into_iter().filter(|c| !c.read_only).collect();
428    let id = |c: &sioul_core::vdir::Collection| format!("{}/{}", c.account, c.id);
429    let usual = crate::backend::load_config().tasks.list.clone().unwrap_or_default();
430    [health.errands_list.clone(), usual]
431        .into_iter()
432        .find(|wanted| !wanted.is_empty() && lists.iter().any(|c| id(c) == *wanted))
433        .or_else(|| lists.iter().find(|c| c.account != sioul_core::vdir::LOCAL).map(id))
434        .or_else(|| lists.first().map(id))
435}
436
437/// The day the page shows (`show_week`): its week is made. None: today.
438static SHOWN: std::sync::Mutex<Option<Date>> = std::sync::Mutex::new(None);
439
440/// The page shows the week of `day` ("2026-10-07"); one that does not read: today's.
441pub(crate) fn show_week(day: &str) {
442    if let Ok(mut shown) = SHOWN.lock() {
443        *shown = day.trim().parse().ok();
444    }
445}
446
447/// `at` (Unix seconds) on the clock in `zone`: "12:30".
448fn clock(at: i64, zone: &TimeZone) -> String {
449    Timestamp::from_second(at).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default()
450}
451
452/// Midnight of `date` in `zone`, Unix seconds.
453fn midnight(date: Date, zone: &TimeZone) -> i64 {
454    date.to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second())
455}
456
457/// "Wednesday": a word's first letter in capitals, as a title starts.
458fn capitalized(text: &str) -> String {
459    let mut chars = text.chars();
460    chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
461}
462
463/// The health page, as JSON: the week of the day it shows, and what only today says.
464pub(crate) fn page() -> String {
465    let health = load();
466    let state = record();
467    let knowledge = know();
468    let now = Zoned::now();
469    let shown = SHOWN.lock().ok().and_then(|s| *s).unwrap_or(now.date());
470    let records = records();
471    let week = week_view(&health, &state, &records, &knowledge, shown, &now);
472    let needs = &health.needs;
473    json(&PageView {
474        any: needs.meals_on || needs.naps_on || needs.sleep_on || !health.medicines.is_empty() || week.days.iter().any(|d| !d.items.is_empty()),
475        week,
476        missed: missed_rows(&health, &state, &told().unwrap_or_default(), &knowledge, &now, tr()),
477        shared_note: if health.medicines.is_empty() { String::new() } else { shared_note(&knowledge) },
478        reminded_there: [
479            REMINDED_THERE.lock().map(|r| r.clone()).unwrap_or_default(),
480            if health.medicines.is_empty() || crate::alarms::notifications_allowed() { String::new() } else { tr().text("dose-notifications-off", None) },
481            if health.medicines.is_empty() || crate::alarms::exact() { String::new() } else { tr().text("dose-alarms-inexact", None) },
482        ]
483        .into_iter()
484        .filter(|t| !t.is_empty())
485        .collect::<Vec<_>>()
486        .join(" "),
487        later: needs.later,
488        medicines: medicine_rows(&health, now.date()),
489        prescriptions: prescription_rows(&health, now.date()),
490    })
491}
492
493/// The page's settings (⚙), as JSON.
494pub(crate) fn settings_view() -> String {
495    let health = load();
496    let config = crate::backend::load_config();
497    json(&SettingsView {
498        movement: health.movement.clone(),
499        chats: health.chats.clone(),
500        errands_list: errands_list(&health).unwrap_or_default(),
501        lists: sioul_core::tasks::lists().into_iter().filter(|c| !c.read_only).map(|c| serde_json::json!({ "id": format!("{}/{}", c.account, c.id), "name": c.label(&config, tr()), "local": c.account == sioul_core::vdir::LOCAL })).collect(),
502    })
503}
504
505/// The events of the week from `monday`, read again five minutes after at most:
506/// the page is made again each minute while it is open.
507fn week_events(monday: Date, zone: &TimeZone) -> Arc<Vec<sioul_core::agenda::Occurrence>> {
508    type Cached = Option<(i64, Date, Arc<Vec<sioul_core::agenda::Occurrence>>)>;
509    static CACHE: std::sync::Mutex<Cached> = std::sync::Mutex::new(None);
510    let stamp = Timestamp::now().as_second();
511    let mut cache = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
512    if let Some((at, day, events)) = cache.as_ref()
513        && *day == monday
514        && (0..300).contains(&(stamp - at))
515    {
516        return Arc::clone(events);
517    }
518    let from = midnight(monday, zone);
519    let to = monday.checked_add(Span::new().days(7)).map_or(from + 7 * 86_400, |d| midnight(d, zone));
520    let events = Arc::new(sioul_core::agenda::occurrences(from, to));
521    *cache = Some((stamp, monday, Arc::clone(&events)));
522    events
523}
524
525/// Which day a night is: the evening it starts, or, its bedtime after
526/// midnight, the evening before (`Needs::kept_with`'s rule).
527fn night_of(kept: &Kept, zone: &TimeZone) -> Option<Date> {
528    let bed = Timestamp::from_second(kept.at).ok()?.to_zoned(zone.clone());
529    if bed.hour() < 12 { bed.date().yesterday().ok() } else { Some(bed.date()) }
530}
531
532/// The week of `day`, Monday to Sunday: each day's list and timeline, and
533/// the hours the timeline shows (the same each day).
534fn week_view(health: &Health, state: &HealthState, records: &DoseRecords, knowledge: &Knowledge, day: Date, now: &Zoned) -> WeekView {
535    let zone = now.time_zone().clone();
536    let monday = day.checked_sub(Span::new().days(i64::from(day.weekday().to_monday_zero_offset()))).unwrap_or(day);
537    let days = days();
538    let events = week_events(monday, &zone);
539    // Meals move past the events with their margins, as the plan has them.
540    let held = sioul_core::plan::event_spans(&events, 0);
541    let steps = crate::work::day_steps();
542    let days: Vec<DayView> = (0..7).filter_map(|n| monday.checked_add(Span::new().days(n)).ok()).map(|date| day_view(health, state, records, knowledge, &days, &Around { events: &events, held: &held, steps: &steps }, date, now)).collect();
543    // From half an hour before the first thing (waking, the first block or
544    // event), to half an hour after the last (bedtime, the last one), whole hours.
545    let (mut first, mut last) = (i64::MAX, i64::MIN);
546    for day in &days {
547        for s in &day.segments {
548            match s.kind {
549                "sleep" if s.from_minute <= 0 => first = first.min(s.to_minute),
550                "sleep" => last = last.max(s.at_minute.min(24 * 60)),
551                _ => {
552                    first = first.min(s.from_minute);
553                    last = last.max(s.to_minute);
554                }
555            }
556        }
557        for c in &day.context {
558            first = first.min(c.from_minute);
559            last = last.max(c.to_minute);
560        }
561    }
562    let first = if first == i64::MAX { 7 * 60 } else { first };
563    let last = if last == i64::MIN { 22 * 60 } else { last };
564    let from = (first - 30).clamp(0, 23 * 60) / 60 * 60;
565    let to = ((last + 30 + 59) / 60 * 60).clamp(from + 6 * 60, 24 * 60);
566    WeekView { monday: monday.to_string(), today: now.date().to_string(), from_minute: from.min(to - 60), to_minute: to, days }
567}
568
569/// What lies around the days: the week's events, their times with margins, today's planned steps.
570struct Around<'a> {
571    events: &'a [sioul_core::agenda::Occurrence],
572    held: &'a [(i64, i64)],
573    steps: &'a [(i64, i64, String)],
574}
575
576/// One day: its list (blocks, those taken out that day, its doses) and what its column draws.
577fn day_view(health: &Health, state: &HealthState, records: &DoseRecords, knowledge: &Knowledge, days: &Days, around: &Around, date: Date, now: &Zoned) -> DayView {
578    let zone = now.time_zone().clone();
579    let today = now.date();
580    let stamp = now.timestamp().as_second();
581    let start = midnight(date, &zone);
582    let next = date.tomorrow().map_or(start + 86_400, |d| midnight(d, &zone));
583    // Minutes from this day's midnight on the clock (a time the next day: past 1440).
584    let minute = |at: i64| -> i64 {
585        let Ok(t) = Timestamp::from_second(at) else { return 0 };
586        let z = t.to_zoned(zone.clone());
587        let after = date.until(z.date()).map_or(0, |s| i64::from(s.get_days()));
588        after * 24 * 60 + i64::from(z.hour()) * 60 + i64::from(z.minute())
589    };
590    let needs = &health.needs;
591    let pushed = needs.past_events_on(date, &zone, days, around.held);
592    let shift = |key: &str| pushed.get(key).copied().unwrap_or(0);
593    let past_day = date < today;
594    let mut items: Vec<(i64, DayItem)> = Vec::new();
595    let mut segments: Vec<Segment> = Vec::new();
596    let block_item = |k: &Kept, off: bool| -> DayItem {
597        let change = days.get(date, &k.key);
598        let added = sioul_core::needs::is_added(&k.key);
599        let (minutes, before, after) = needs.lengths(date, &k.key, days).unwrap_or((0, 0, 0));
600        let detail = match k.kind {
601            "meal" if before > 0 => say("need-meal-detail", &[("at", clock(k.at, &zone))]),
602            "nap" if after > 0 => say("need-nap-detail", &[("minutes", after.to_string())]),
603            "sleep" => say("need-night-detail", &[("bed", clock(k.at, &zone))]),
604            _ => String::new(),
605        };
606        // The night's alarm at waking, said quietly; "No alarm" asked, said as how that day differs.
607        let (alarm, alarm_skipped) = if k.kind == "sleep" && !off { crate::wake::of_night(needs, date, &zone, days) } else { (String::new(), false) };
608        let alarm_open = !alarm.is_empty() && needs.alarm_of(date, &zone, days).is_some_and(|a| a.at > stamp);
609        let detail = if alarm.is_empty() || alarm_skipped { detail } else { format!("{detail}  ·  {}", say("wake-at", &[("time", alarm.clone())])) };
610        let changed = !added && change.is_some_and(|c| !c.at.is_empty() || !c.wake.is_empty() || c.minutes.is_some() || c.before.is_some() || c.after.is_some());
611        let quiet = change.is_some_and(|c| c.quiet);
612        let note = if off {
613            tr().text("need-off", None)
614        } else if added {
615            tr().text("need-added", None)
616        } else if changed {
617            tr().text("need-changed", None)
618        } else if shift(&k.key) > 0 {
619            tr().text("need-pushed", None)
620        } else {
621            String::new()
622        };
623        let note = if quiet && !off { [note, tr().text("need-quiet", None)].into_iter().filter(|n| !n.is_empty()).collect::<Vec<_>>().join(" · ") } else { note };
624        let note = if alarm_skipped { [note, tr().text("wake-none", None)].into_iter().filter(|n| !n.is_empty()).collect::<Vec<_>>().join(" · ") } else { note };
625        DayItem {
626            key: k.key.clone(),
627            kind: k.kind,
628            name: if k.kind == "sleep" { tr().text("needs-sleep", None) } else { block_name(k.kind, &k.key, &k.name) },
629            dose: String::new(),
630            from: clock(k.start, &zone),
631            to: clock(k.end, &zone),
632            at: clock(k.at, &zone),
633            detail,
634            note,
635            changed,
636            added,
637            quiet,
638            off,
639            past: past_day || k.end <= stamp,
640            minutes,
641            before,
642            after,
643            taken: String::new(),
644            late: false,
645            doubt: String::new(),
646            doubt_off: Vec::new(),
647            choose: false,
648            alarm,
649            alarm_skipped,
650            alarm_open,
651        }
652    };
653    for k in needs.blocks_of(date, &zone, days, &shift) {
654        let item = block_item(&k, false);
655        // Before and after it proper, lighter: getting it ready, winding down, coming back.
656        let until = if k.kind == "sleep" { k.end } else { k.at + i64::from(item.minutes) * 60 };
657        if minute(k.start) < 24 * 60 {
658            segments.push(Segment { date: date.to_string(), key: k.key.clone(), kind: k.kind, name: item.name.clone(), from_minute: minute(k.start).max(0), to_minute: minute(k.end).min(24 * 60), start_minute: minute(k.start), end_minute: minute(k.end), at_minute: minute(k.at), until_minute: minute(until), quiet: item.quiet, past: item.past, state: "" });
659        }
660        items.push((minute(k.start), item));
661    }
662    // Taken out that day: in the list still, to put back; its time is free.
663    let usual = needs.blocks_of(date, &zone, &Days::default(), &|_| 0);
664    for k in usual.iter().filter(|k| days.get(date, &k.key).is_some_and(|c| c.off)) {
665        items.push((minute(k.start), block_item(k, true)));
666    }
667    // The night ending this morning is the day before's: its morning part.
668    for k in needs.kept_with(date, &zone, days, &shift).iter().filter(|k| k.kind == "sleep") {
669        let Some(night) = night_of(k, &zone).filter(|night| *night != date) else { continue };
670        segments.push(Segment {
671            date: night.to_string(),
672            key: k.key.clone(),
673            kind: "sleep",
674            name: tr().text("needs-sleep", None),
675            from_minute: minute(k.start).max(0),
676            to_minute: minute(k.end).min(24 * 60),
677            start_minute: minute(k.start),
678            end_minute: minute(k.end),
679            at_minute: minute(k.at),
680            until_minute: minute(k.end),
681            quiet: days.get(night, "sleep").is_some_and(|c| c.quiet),
682            past: night < today || k.end <= stamp,
683            state: "",
684        });
685    }
686    // The doses: on today with whether they are marked, and the doubt when
687    // another device may know; another day, plainly (no record of the past here).
688    let (from_z, to_z) = (Timestamp::from_second(start).map(|t| t.to_zoned(zone.clone())), Timestamp::from_second(next).map(|t| t.to_zoned(zone.clone())));
689    if let (Ok(from_z), Ok(to_z)) = (from_z, to_z) {
690        let mut doses: Vec<(String, i64, String, String)> = health.doses(&from_z, &to_z).into_iter().map(|d| (d.key, d.at.timestamp().as_second(), d.name, d.dose)).collect();
691        // Taken today, at a time the schedule no longer has: a dose taken late
692        // or early moved the next ones (a medicine counted from its last dose).
693        if date == today {
694            for key in state.taken.keys() {
695                let Some((id, due)) = key.rsplit_once('@') else { continue };
696                let (Some(medicine), Ok(due)) = (health.medicines.iter().find(|m| m.id == id), due.parse::<i64>()) else { continue };
697                if due >= start && due < next && !doses.iter().any(|d| &d.0 == key) {
698                    doses.push((key.clone(), due, medicine.short_name(), health.amount_of(key, &zone).unwrap_or_default()));
699                }
700            }
701        }
702        for (key, due, name, dose) in doses {
703            // Today, as your devices answered it: taken (the earliest answer),
704            // or answers that differ, both said; else the doubt when another
705            // device may hold an answer (never "not taken").
706            let answered = if date == today { sioul_core::doses::answered(&key, state, records) } else { Answered::Open };
707            let (taken, marked) = match &answered {
708                Answered::Taken(given) => (clock(given.at, &zone), true),
709                _ => (String::new(), false),
710            };
711            let (doubt, doubt_off) = match &answered {
712                Answered::Differ(given) => (differ_sentence(given, &this_device(), tr()), Vec::new()),
713                _ if date != today || marked || due > stamp => (String::new(), Vec::new()),
714                _ => doubt_now_with(knowledge, due, stamp, tr()),
715            };
716            let item = DayItem {
717                taken,
718                choose: matches!(answered, Answered::Differ(_)),
719                doubt_off,
720                late: date == today && !marked && stamp - due > GRACE_MINUTES * 60,
721                key: key.clone(),
722                kind: "dose",
723                name: name.clone(),
724                dose,
725                from: clock(due, &zone),
726                to: String::new(),
727                at: clock(due, &zone),
728                detail: String::new(),
729                note: String::new(),
730                changed: false,
731                added: false,
732                quiet: false,
733                off: false,
734                past: past_day || due <= stamp,
735                minutes: 0,
736                before: 0,
737                after: 0,
738                doubt,
739                alarm: String::new(),
740                alarm_skipped: false,
741                alarm_open: false,
742            };
743            let state = if date != today { "" } else if marked { "taken" } else if item.doubt.is_empty() { "due" } else { "check" };
744            segments.push(Segment { date: date.to_string(), key, kind: "dose", name, from_minute: minute(due), to_minute: minute(due), start_minute: minute(due), end_minute: minute(due), at_minute: minute(due), until_minute: minute(due), quiet: false, past: item.past, state });
745            items.push((minute(due), item));
746        }
747    }
748    items.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| (a.1.kind == "dose").cmp(&(b.1.kind == "dose"))));
749    // The day's events, and today's planned steps, for context; whole days and errands in words.
750    let mut context = Vec::new();
751    let mut lines = Vec::new();
752    for event in around.events.iter().filter(|e| !e.cancelled && e.end > start && e.start < next) {
753        if event.all_day {
754            lines.push(say("day-all-day", &[("what", event.summary.clone())]));
755        } else {
756            context.push(ContextItem { title: event.summary.clone(), kind: "event", from_minute: minute(event.start).max(0), to_minute: minute(event.end.max(event.start + 15 * 60)).min(24 * 60) });
757        }
758    }
759    if date == today {
760        for (from, to, title) in around.steps.iter().filter(|(from, to, _)| *to > start && *from < next) {
761            context.push(ContextItem { title: title.clone(), kind: "task", from_minute: minute(*from).max(0), to_minute: minute(*to).min(24 * 60) });
762        }
763    }
764    context.sort_by_key(|c| c.from_minute);
765    for errand in health.errands().into_iter().filter(|e| e.day == date) {
766        let what = say(if errand.kind == ErrandKind::Refill { "health-errand-refill" } else { "health-errand-renew" }, &[("title", errand.title.clone())]);
767        lines.push(say("health-errand-day", &[("what", what)]));
768    }
769    let named = tr().day(date);
770    let title = if date == today {
771        format!("{}, {named}", tr().text("agenda-today", None))
772    } else if today.tomorrow().is_ok_and(|t| t == date) {
773        format!("{}, {named}", tr().text("agenda-tomorrow", None))
774    } else {
775        capitalized(&named)
776    };
777    DayView {
778        date: date.to_string(),
779        title,
780        weekday: tr().weekday_short(date),
781        number: date.day(),
782        today: date == today,
783        past: past_day,
784        items: items.into_iter().map(|(_, item)| item).collect(),
785        segments,
786        context,
787        lines,
788    }
789}
790
791/// Doses neither marked nor reminded: a question on the past, each saying
792/// when whether it was taken is not known here. Three questions, each its
793/// own heading: those due while Sioul ran nowhere, then those due while it
794/// ran but whose reminder could not be shown (no notification server: never
795/// said "while Sioul was closed"), then those whose reminder went and was not
796/// known shown (`told`: what each device did); by their time within each.
797fn missed_rows(health: &Health, state: &HealthState, told: &DoseReminders, knowledge: &Knowledge, now: &Zoned, words: &Translator) -> Vec<DoseRow> {
798    let maybe = |key: &str| told.maybe_anywhere(key).is_some();
799    let mut rows: Vec<DoseRow> = state
800        .unanswered(health, now, MISSED_HOURS, GRACE_MINUTES)
801        .into_iter()
802        .map(|d| {
803            let (doubt, doubt_off) = doubt_now_with(knowledge, d.at.timestamp().as_second(), now.timestamp().as_second(), words);
804            DoseRow {
805                taken: String::new(),
806                past: true,
807                late: true,
808                doubt,
809                doubt_off,
810                choose: false,
811                question: if state.reminder_unshown(&d.key) && maybe(&d.key) {
812                    words.text("health-maybe-question", None)
813                } else if state.reminder_unshown(&d.key) {
814                    words.text("health-unshown-question", None)
815                } else {
816                    words.text("health-missed-question", None)
817                },
818                time: if d.at.date() == now.date() { d.at.strftime("%H:%M").to_string() } else { format!("{} {}", words.weekday_short(d.at.date()), d.at.strftime("%H:%M")) },
819                key: d.key,
820                name: d.name,
821                dose: d.dose,
822            }
823        })
824        .collect();
825    rows.sort_by_key(|row| (state.reminder_unshown(&row.key), maybe(&row.key)));
826    rows
827}
828
829/// The notification's title for the question on doses neither marked nor
830/// reminded (`missed`): "While Sioul was closed" only when it was; "A
831/// reminder could not be shown" when Sioul ran; both kinds, a title true of
832/// both.
833fn missed_title(missed: &[sioul_core::health::Dose], state: &HealthState, told: &DoseReminders, words: &Translator) -> String {
834    let maybe = missed.iter().filter(|d| state.reminder_unshown(&d.key) && told.maybe_anywhere(&d.key).is_some()).count();
835    let unshown = missed.iter().filter(|d| state.reminder_unshown(&d.key)).count() - maybe;
836    match (unshown, maybe) {
837        (0, 0) => words.text("health-missed", None),
838        (n, 0) if n == missed.len() => words.text("health-unshown", None),
839        (0, n) if n == missed.len() => words.text("health-maybe", None),
840        _ => words.text("health-missed-some", None),
841    }
842}
843
844/// What a minute of `tick` did, written in the doses' record: the reminders
845/// shown, those that surely could not be, and those sent whose fate is not
846/// known, both `unshown` (the question on the past says Sioul ran; an older
847/// Sioul reminds them; which of the two, and what each device did, is in
848/// `dose_reminders`), and the errands' tasks made.
849fn note_minute(record: &mut HealthState, reminded: &[String], unshown: &[String], maybe: &[String], made: &[(String, String)], stamp: i64) {
850    for key in reminded {
851        record.reminded.insert(key.clone(), stamp);
852    }
853    for key in unshown.iter().chain(maybe) {
854        record.unshown.entry(key.clone()).or_insert(stamp);
855    }
856    for (key, uid) in made {
857        record.errands.insert(key.clone(), uid.clone());
858    }
859}
860
861/// The Porch's doses (docs/health.md, "On the Porch"). `due`: today's, from
862/// their time on, not marked yet, reminded or not (a notification can go
863/// unseen), each with "Taken" (past its half hour, when it was taken is asked)
864/// and the doubt when another device may know; until marked, the day's end,
865/// or twelve hours after its time. `closed`: those due while Sioul ran
866/// nowhere, the question on the past, once your other devices were heard
867/// from (`heard`), so that a dose marked there is not asked about here.
868/// Whatever the hours: doses are not mail. None while you sleep with doses
869/// staying silent (`silent`): they come at waking.
870#[derive(Default, Serialize)]
871struct PorchDoses {
872    due: Vec<DoseRow>,
873    closed: Vec<DoseRow>,
874}
875
876/// `records` and `me` (this device's id): today's doses whose answers differ
877/// on your devices come too, both answers said, until you choose.
878#[allow(clippy::too_many_arguments)]
879fn porch_doses(health: &Health, state: &HealthState, records: &DoseRecords, told: &DoseReminders, me: &str, knowledge: &Knowledge, now: &Zoned, silent: bool, heard: bool, words: &Translator) -> PorchDoses {
880    if silent || health.medicines.is_empty() {
881        return PorchDoses::default();
882    }
883    let stamp = now.timestamp().as_second();
884    let row = |d: sioul_core::health::Dose, doubt: String, doubt_off: Vec<OffButton>, choose: bool| {
885        let at = d.at.timestamp().as_second();
886        DoseRow { taken: String::new(), past: true, late: stamp - at > GRACE_MINUTES * 60, doubt, doubt_off, choose, question: String::new(), time: d.at.strftime("%H:%M").to_string(), key: d.key, name: d.name, dose: d.dose }
887    };
888    // Answered in the records, their marks not here (yet): answered all the same; never due twice.
889    let mut due: Vec<DoseRow> = state
890        .due_today(health, now, MISSED_HOURS, GRACE_MINUTES)
891        .into_iter()
892        .filter(|d| sioul_core::doses::answered(&d.key, state, records) == Answered::Open)
893        .map(|d| {
894            let (doubt, doubt_off) = doubt_now_with(knowledge, d.at.timestamp().as_second(), stamp, words);
895            row(d, doubt, doubt_off, false)
896        })
897        .collect();
898    // Answered on two devices, differently: both said, as far back as today's doses show.
899    let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
900    let back = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
901    let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
902    for d in health.doses(if back > midnight { &back } else { &midnight }, &end) {
903        if let Answered::Differ(given) = sioul_core::doses::answered(&d.key, state, records) {
904            let sentence = differ_sentence(&given, me, words);
905            due.push(row(d, sentence, Vec::new(), true));
906        }
907    }
908    due.sort_by_key(|row| due_of(&row.key).unwrap_or(0));
909    PorchDoses { due, closed: if heard { missed_rows(health, state, told, knowledge, now, words) } else { Vec::new() } }
910}
911
912/// The Porch's doses, as JSON (`PorchDoses`), made off the window's thread.
913pub(crate) fn missed() -> String {
914    let health = load();
915    if health.medicines.is_empty() {
916        return json(&PorchDoses::default());
917    }
918    let heard = !crate::share::on() || crate::share::last_exchange().is_some();
919    json(&porch_doses(&health, &record(), &records(), &told().unwrap_or_default(), &this_device(), &know(), &Zoned::now(), crate::hours::doses_silent(), heard, tr()))
920}
921
922/// A medicine as its form gives it.
923#[derive(Deserialize)]
924struct MedicineEdit {
925    /// Its brand name, or your own word for it; "" when its generic name says it.
926    #[serde(default)]
927    name: String,
928    /// The generic name of its molecule (INN), and its strength: optional.
929    #[serde(default)]
930    generic: String,
931    #[serde(default)]
932    strength: String,
933    /// "2026-03-02", the day it was first taken; "" when not known.
934    #[serde(default)]
935    since: String,
936    /// Its usual amount: each take's when it says none.
937    #[serde(default)]
938    dose: String,
939    /// "day", "days", "hours".
940    every: String,
941    /// At set times each day: each take, its time and its own amount ("" for the usual).
942    #[serde(default)]
943    takes: Vec<Take>,
944    /// The times alone, as the form gave them before takes had rows.
945    #[serde(default)]
946    times: Vec<String>,
947    #[serde(default)]
948    days: u32,
949    #[serde(default)]
950    hours: u32,
951    /// "2026-10-04"; for hours, "2026-10-03T18:30".
952    #[serde(default)]
953    from: String,
954    #[serde(default)]
955    time: String,
956    #[serde(default)]
957    until: String,
958    #[serde(default)]
959    prescription: String,
960    #[serde(default)]
961    paused: bool,
962}
963
964fn answer(result: Result<String, String>) -> String {
965    match result {
966        Ok(id) => serde_json::json!({ "id": id }).to_string(),
967        Err(error) => serde_json::json!({ "error": error }).to_string(),
968    }
969}
970
971/// Why takes could not be set, in words; with the medicine's name in a
972/// prescription's form, where several are.
973fn take_problem(problem: &TakeProblem, name: Option<&str>, words: &Translator) -> String {
974    let id = match problem {
975        TakeProblem::None => "health-no-take",
976        TakeProblem::Unreadable(_) => "health-take-unreadable",
977        TakeProblem::Twice(_) => "health-take-twice",
978    };
979    let mut pairs = vec![];
980    if let TakeProblem::Unreadable(time) | TakeProblem::Twice(time) = problem {
981        pairs.push(("time", time.clone()));
982    }
983    match name {
984        Some(name) => {
985            pairs.push(("name", name.to_string()));
986            said(words, &format!("{id}-of"), &pairs)
987        }
988        None => said(words, id, &pairs),
989    }
990}
991
992/// A medicine made (`id` empty) or changed in `health`, as its form gives
993/// it: its id, or what is wrong, nothing changed then.
994fn apply_medicine(health: &mut Health, id: &str, edit: MedicineEdit, now: &Zoned, words: &Translator) -> Result<String, String> {
995    // Its name, else its generic name: reminders always have one, and so does an older Sioul.
996    let name = if edit.name.trim().is_empty() { edit.generic.trim() } else { edit.name.trim() }.to_string();
997    if name.is_empty() {
998        return Err(words.text("health-no-name", None));
999    }
1000    let day = |text: &str| text.trim().parse::<Date>().ok();
1001    let id = if id.is_empty() { health.new_id(&name) } else { id.to_string() };
1002    let mut medicine = Medicine {
1003        id: id.clone(),
1004        name,
1005        generic: edit.generic.trim().to_string(),
1006        strength: edit.strength.trim().to_string(),
1007        since: day(&edit.since),
1008        dose: edit.dose.trim().to_string(),
1009        schedule: Schedule::Day { times: Vec::new(), amounts: Default::default() },
1010        prescription: Some(edit.prescription).filter(|p| !p.is_empty()),
1011        until: day(&edit.until),
1012        paused: edit.paused,
1013    };
1014    match edit.every.as_str() {
1015        "days" => medicine.schedule = Schedule::Days { days: edit.days.max(1), time: edit.time.trim().to_string(), from: day(&edit.from).unwrap_or(now.date()) },
1016        "hours" => {
1017            let from = edit.from.trim().parse::<jiff::civil::DateTime>().ok().and_then(|d| d.to_zoned(now.time_zone().clone()).ok()).map_or(now.timestamp().as_second(), |z| z.timestamp().as_second());
1018            medicine.schedule = Schedule::Hours { hours: edit.hours.max(1), from };
1019        }
1020        _ => {
1021            let takes: Vec<Take> = if edit.takes.is_empty() { edit.times.iter().filter(|t| !t.trim().is_empty()).map(|t| Take { time: t.clone(), amount: String::new() }).collect() } else { edit.takes };
1022            medicine.set_takes(&edit.dose, &takes).map_err(|problem| take_problem(&problem, None, words))?;
1023        }
1024    }
1025    match health.medicines.iter_mut().find(|m| m.id == id) {
1026        Some(slot) => *slot = medicine,
1027        None => health.medicines.push(medicine),
1028    }
1029    Ok(id)
1030}
1031
1032/// A medicine made (`id` empty) or changed; returns {"id"} or {"error"}.
1033pub(crate) fn save_medicine(id: &str, edit: &str) -> String {
1034    let result = serde_json::from_str::<MedicineEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
1035        change_health(|health| apply_medicine(health, id, edit, &Zoned::now(), tr()))
1036    });
1037    answer(result)
1038}
1039
1040/// A prescription as its form gives it.
1041#[derive(Deserialize)]
1042struct PrescriptionEdit {
1043    title: String,
1044    #[serde(default)]
1045    prescriber: String,
1046    #[serde(default)]
1047    until: String,
1048    #[serde(default)]
1049    refill_days: u32,
1050    #[serde(default)]
1051    last_refill: String,
1052    #[serde(default)]
1053    note: String,
1054    /// Its medicines, row by row; none given: they stay as they are.
1055    #[serde(default)]
1056    medicines: Option<Vec<PrescribedEdit>>,
1057}
1058
1059/// A medicine as a row of its prescription's form gives it.
1060#[derive(Deserialize)]
1061struct PrescribedEdit {
1062    /// "" for one added in the form.
1063    #[serde(default)]
1064    id: String,
1065    #[serde(default)]
1066    name: String,
1067    /// The generic name of its molecule (INN), and its strength: optional.
1068    #[serde(default)]
1069    generic: String,
1070    #[serde(default)]
1071    strength: String,
1072    /// Its usual amount: each take's when it says none.
1073    #[serde(default)]
1074    dose: String,
1075    /// Its takes, at set times each day (a medicine taken every few days or
1076    /// hours keeps its own schedule: its form changes it).
1077    #[serde(default)]
1078    takes: Vec<Take>,
1079    /// Taken out in the form: the medicine goes when the form is saved.
1080    #[serde(default)]
1081    removed: bool,
1082}
1083
1084/// A prescription made (`id` empty) or changed in `health`, with its
1085/// medicines as its rows give them: made, changed, taken out. Every row is
1086/// read before anything changes: what does not save changes nothing. Its id,
1087/// or what is wrong.
1088fn apply_prescription(health: &mut Health, id: &str, edit: PrescriptionEdit, words: &Translator) -> Result<String, String> {
1089    if edit.title.trim().is_empty() {
1090        return Err(words.text("health-no-name", None));
1091    }
1092    let id = if id.is_empty() { health.new_id(&edit.title) } else { id.to_string() };
1093    let mut made: Vec<Medicine> = Vec::new();
1094    let mut gone: Vec<String> = Vec::new();
1095    for row in edit.medicines.iter().flatten() {
1096        if row.removed {
1097            gone.extend(Some(row.id.clone()).filter(|id| !id.is_empty()));
1098            continue;
1099        }
1100        // Its name, else its generic name.
1101        let name = if row.name.trim().is_empty() { row.generic.trim() } else { row.name.trim() };
1102        // A row added and left as it came: nothing to take.
1103        if row.id.is_empty() && name.is_empty() && row.dose.trim().is_empty() && row.strength.trim().is_empty() && row.takes.iter().all(|t| t.amount.trim().is_empty()) {
1104            continue;
1105        }
1106        if name.is_empty() {
1107            return Err(words.text("health-medicine-no-name", None));
1108        }
1109        let mut medicine = health.medicines.iter().find(|m| !row.id.is_empty() && m.id == row.id).cloned().unwrap_or_else(|| Medicine {
1110            id: row.id.clone(),
1111            name: String::new(),
1112            dose: String::new(),
1113            schedule: Schedule::Day { times: Vec::new(), amounts: Default::default() },
1114            prescription: None,
1115            until: None,
1116            paused: false,
1117            generic: String::new(),
1118            strength: String::new(),
1119            since: None,
1120        });
1121        medicine.name = name.to_string();
1122        medicine.generic = row.generic.trim().to_string();
1123        medicine.strength = row.strength.trim().to_string();
1124        medicine.prescription = Some(id.clone());
1125        if matches!(medicine.schedule, Schedule::Day { .. }) {
1126            medicine.set_takes(&row.dose, &row.takes).map_err(|problem| take_problem(&problem, Some(name), words))?;
1127        } else {
1128            medicine.dose = row.dose.trim().to_string();
1129        }
1130        made.push(medicine);
1131    }
1132    let prescription = Prescription {
1133        id: id.clone(),
1134        title: edit.title.trim().to_string(),
1135        prescriber: edit.prescriber.trim().to_string(),
1136        until: edit.until.trim().parse().ok(),
1137        refill_days: Some(edit.refill_days).filter(|d| *d > 0),
1138        last_refill: edit.last_refill.trim().parse().ok(),
1139        note: edit.note.trim().to_string(),
1140    };
1141    match health.prescriptions.iter_mut().find(|p| p.id == id) {
1142        Some(slot) => *slot = prescription,
1143        None => health.prescriptions.push(prescription),
1144    }
1145    health.medicines.retain(|m| !gone.contains(&m.id));
1146    for mut medicine in made {
1147        if medicine.id.is_empty() {
1148            medicine.id = health.new_id(&medicine.name);
1149        }
1150        match health.medicines.iter_mut().find(|m| m.id == medicine.id) {
1151            Some(slot) => *slot = medicine,
1152            None => health.medicines.push(medicine),
1153        }
1154    }
1155    Ok(id)
1156}
1157
1158/// A prescription made (`id` empty) or changed, with its medicines; returns {"id"} or {"error"}.
1159pub(crate) fn save_prescription(id: &str, edit: &str) -> String {
1160    let result = serde_json::from_str::<PrescriptionEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
1161        change_health(|health| apply_prescription(health, id, edit, tr()))
1162    });
1163    answer(result)
1164}
1165
1166/// What a doctor or a pharmacist is shown, read-only, at arm's length
1167/// (`qml/ShowMedicines.qml`): a prescription's medicines, or all those taken now.
1168#[derive(Serialize, Debug, PartialEq)]
1169struct ForProfessional {
1170    /// The prescription's title, or "Current medicines".
1171    title: String,
1172    /// Who prescribed it and until when it is valid; for all, the day shown.
1173    about: Vec<String>,
1174    medicines: Vec<Shown>,
1175    /// No medicine to show, said; "" otherwise.
1176    empty: String,
1177}
1178
1179/// One medicine, for a professional.
1180#[derive(Serialize, Debug, PartialEq)]
1181struct Shown {
1182    /// Its generic name and strength, first: "levothyroxine 75 µg"; its name
1183    /// and strength when no generic name is given.
1184    molecule: String,
1185    /// Its brand name, or your word for it, when it is not the generic name.
1186    brand: String,
1187    /// Each take, its time and amount ("07:30 — 1 tablet"); every few days
1188    /// or hours in words, with the amount.
1189    takes: Vec<String>,
1190    /// "Taken since Monday 2 March, 7 months"; "" when not known.
1191    since: String,
1192    /// "Prescribed by Dr Elena Varga", when the medicine's prescription says
1193    /// who, and the view is not that prescription's (it says so above).
1194    prescriber: String,
1195    /// "Until Wednesday 28 October", "Paused for now".
1196    notes: Vec<String>,
1197}
1198
1199/// How long since `since`, in words: days, then weeks, months, years.
1200fn how_long(since: Date, today: Date, words: &Translator) -> String {
1201    let days = since.until(today).map_or(0, |s| i64::from(s.get_days())).max(0);
1202    let (id, n) = if days == 0 {
1203        return String::new();
1204    } else if days < 14 {
1205        ("health-pro-days", days)
1206    } else if days < 61 {
1207        ("health-pro-weeks", days / 7)
1208    } else if days < 730 {
1209        ("health-pro-months", days * 12 / 365)
1210    } else {
1211        ("health-pro-years", days / 365)
1212    };
1213    words.text(id, Some(&words.counted(usize::try_from(n).unwrap_or(0))))
1214}
1215
1216/// A prescription's medicines (`prescription`, its id), or those taken now
1217/// ("": not paused, not past their last day), for a professional, in your
1218/// language; the generic names as typed (international).
1219fn for_professional_of(health: &Health, prescription: &str, today: Date, words: &Translator) -> ForProfessional {
1220    let p = health.prescriptions.iter().find(|p| p.id == prescription);
1221    let medicines: Vec<&Medicine> = match p {
1222        Some(p) => tied(health, p).collect(),
1223        None => health.medicines.iter().filter(|m| !m.paused && m.until.is_none_or(|day| day >= today)).collect(),
1224    };
1225    let shown = medicines
1226        .into_iter()
1227        .map(|m| {
1228            let (generic, strength) = (m.generic.trim(), m.strength.trim());
1229            let first = if generic.is_empty() { m.name.trim() } else { generic };
1230            let molecule = [first, strength].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ");
1231            let brand = if generic.is_empty() || m.name.trim().to_lowercase() == generic.to_lowercase() { String::new() } else { m.name.trim().to_string() };
1232            let takes = match &m.schedule {
1233                Schedule::Day { .. } => m.takes().into_iter().map(|t| if t.amount.is_empty() { t.time } else { format!("{} — {}", t.time, t.amount) }).collect(),
1234                schedule => vec![[words_of(schedule, words), m.dose.trim().to_string()].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" — ")],
1235            };
1236            let since = m
1237                .since
1238                .map(|day| match how_long(day, today, words) {
1239                    long if long.is_empty() => said(words, "health-pro-since-day", &[("day", words.day_in(day, today))]),
1240                    long => said(words, "health-pro-since", &[("day", words.day_in(day, today)), ("long", long)]),
1241                })
1242                .unwrap_or_default();
1243            let prescriber = match (p, m.prescription.as_deref().and_then(|id| health.prescriptions.iter().find(|p| p.id == id))) {
1244                (None, Some(of)) if !of.prescriber.trim().is_empty() => said(words, "health-pro-prescribed-by", &[("name", of.prescriber.trim().to_string())]),
1245                _ => String::new(),
1246            };
1247            let mut notes: Vec<String> = m.until.map(|day| said(words, "health-pro-until", &[("day", words.day_in(day, today))])).into_iter().collect();
1248            if m.paused {
1249                notes.push(words.text("health-pause", None));
1250            }
1251            Shown { molecule, brand, takes, since, prescriber, notes }
1252        })
1253        .collect::<Vec<_>>();
1254    let about = match p {
1255        Some(p) => [
1256            Some(p.prescriber.trim()).filter(|w| !w.is_empty()).map(|name| said(words, "health-pro-prescribed-by", &[("name", name.to_string())])),
1257            p.until.map(|day| said(words, "health-pro-valid-until", &[("day", words.day_in(day, today))])),
1258        ]
1259        .into_iter()
1260        .flatten()
1261        .collect(),
1262        None => vec![said(words, "health-pro-as-of", &[("day", format!("{} {}", words.day(today), today.year()))])],
1263    };
1264    ForProfessional {
1265        title: p.map_or_else(|| words.text("health-pro-current", None), |p| p.title.clone()),
1266        about,
1267        empty: if shown.is_empty() { words.text("health-pro-none", None) } else { String::new() },
1268        medicines: shown,
1269    }
1270}
1271
1272/// When a medicine taken every few days or hours is taken, in `words`; at set times, its times.
1273fn words_of(schedule: &Schedule, words: &Translator) -> String {
1274    // Numbers as numbers: "every other day" is chosen by the number 2.
1275    let mut args = sioul_core::i18n::args();
1276    match schedule {
1277        Schedule::Days { days, time, from } => {
1278            args.set("days", *days);
1279            args.set("time", time.clone());
1280            args.set("from", words.day(*from));
1281            words.text("health-every-days", Some(&args))
1282        }
1283        Schedule::Hours { hours, .. } => {
1284            args.set("hours", *hours);
1285            words.text("health-pro-every-hours", Some(&args))
1286        }
1287        Schedule::Day { .. } => self::words(schedule),
1288    }
1289}
1290
1291/// "Show to a doctor or pharmacist": a prescription's medicines (its id), or
1292/// all those taken now (""), as JSON (`ForProfessional`).
1293pub(crate) fn for_professional(prescription: &str) -> String {
1294    json(&for_professional_of(&load(), prescription, Zoned::now().date(), tr()))
1295}
1296
1297/// A medicine or a prescription taken out; returns what went wrong, else "".
1298pub(crate) fn remove(id: &str) -> String {
1299    change_health(|health| {
1300        health.medicines.retain(|m| m.id != id);
1301        health.prescriptions.retain(|p| p.id != id);
1302        for medicine in health.medicines.iter_mut().filter(|m| m.prescription.as_deref() == Some(id)) {
1303            medicine.prescription = None;
1304        }
1305        Ok(())
1306    })
1307    .err()
1308    .unwrap_or_default()
1309}
1310
1311/// Fetched at the pharmacy today: the next refill is counted from now.
1312pub(crate) fn refilled(id: &str) -> String {
1313    change_health(|health| {
1314        if let Some(p) = health.prescriptions.iter_mut().find(|p| p.id == id) {
1315            p.last_refill = Some(Zoned::now().date());
1316        }
1317        Ok(())
1318    })
1319    .err()
1320    .unwrap_or_default()
1321}
1322
1323/// How far back a dose due while Sioul was closed is asked about, and the
1324/// minutes a dose is reminded live before that.
1325const MISSED_HOURS: i64 = 12;
1326const GRACE_MINUTES: i64 = 30;
1327/// When the pause to move was last offered, or Sioul started (Unix seconds).
1328static MOVED: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1329
1330/// The pause to move and stretch, every so many minutes since Sioul started
1331/// or since the last one, without waiting for a focus session; a session
1332/// running offers its own (`FocusWindow.qml`). A desktop notification: it
1333/// reaches you with the window hidden, from the computer you are at. A phone
1334/// makes none (docs/android.md, "Notifications"): nothing is tried there, so
1335/// that its status line never says notifications are not made on Android
1336/// (the owner's new phone, 16 October 2026).
1337fn movement_tick(qt: &QtThread, shared: &Arc<Shared>, health: &Health, now: &Zoned, here: bool) {
1338    use std::sync::atomic::Ordering;
1339    if cfg!(target_os = "android") {
1340        return;
1341    }
1342    let stamp = now.timestamp().as_second();
1343    let last = MOVED.load(Ordering::Relaxed);
1344    // At a time the matrix of what reaches you drops it (as usual: sleep, the pauses, a slot
1345    // of time for you, do-not-disturb), the pause is counted again from its end.
1346    if last == 0 || !health.movement.enabled || sioul_core::timelog::running().is_some() || !crate::hours::comes(sioul_core::attention::Kind::Move) {
1347        MOVED.store(stamp, Ordering::Relaxed);
1348        return;
1349    }
1350    if !here || stamp - last < i64::from(health.movement.minutes.max(10)) * 60 {
1351        return;
1352    }
1353    MOVED.store(stamp, Ordering::Relaxed);
1354    let qt_open = qt.clone();
1355    let open: Box<dyn FnOnce() + Send> = Box::new(move || {
1356        let _ = qt_open.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("stopped"), QString::default(), QString::default()));
1357    });
1358    if let Err(e) = sioul_sync::notify::remind(&tr().text("health-move", None), &tr().text("health-move-body", None), Some((tr().text("stopped-note", None), open))) {
1359        tell(qt, shared, e);
1360    }
1361}
1362
1363/// The question on doses due while Sioul was closed: asked once a session.
1364static ASKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1365/// Doses reminded in this session: never twice, even when the record cannot be written.
1366static SENT: std::sync::Mutex<std::collections::BTreeSet<String>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
1367/// "Reminders come on `<computer>`", when another computer keeps them.
1368static REMINDED_THERE: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
1369
1370/// Why the doses here may not be all: this computer alone, or what is not
1371/// known of your other devices now; "" when all is known.
1372fn shared_note(knowledge: &Knowledge) -> String {
1373    if !crate::share::on() {
1374        return tr().text("health-alone", None);
1375    }
1376    let now = Timestamp::now().as_second();
1377    // As for a dose due a while ago (the wait for news): a device in use whose
1378    // news is fresh is known by then; what is said here lasts (a device silent
1379    // while in use, an older Sioul, a record that does not read).
1380    let doubts = sioul_core::health::doubts_now(now - WAIT_FOR_NEWS, now, knowledge.record_lost, &knowledge.peers);
1381    let mut lines: Vec<String> = Vec::new();
1382    if !doubts.is_empty() {
1383        lines.push(say("dose-doubt-now", &[("why", why(&doubts))]));
1384    }
1385    // Devices you said are off: said plainly, for as long as it lasts.
1386    for peer in knowledge.peers.iter().filter(|p| p.off) {
1387        lines.push(say("dose-off-note", &[("name", device_start(&named_of(peer), tr()))]));
1388    }
1389    lines.join(" ")
1390}
1391
1392/// The Health page, the doses due while closed and today's meals and naps,
1393/// made on a thread (they read the doses' record and the other devices'
1394/// claims, a shared folder that may be slow) and shown.
1395pub(crate) fn show_health(qt: &QtThread, shared: &Arc<Shared>) {
1396    let qt = qt.clone();
1397    crate::backend::coalesced(shared, |s| &s.health_job, move |_| {
1398        let (view, missed, needs) = (page(), missed(), needs_today());
1399        if cfg!(target_os = "android") {
1400            eprintln!("sioul: health: {} medicines, {} prescriptions", load().medicines.len(), load().prescriptions.len());
1401        }
1402        let _ = qt.queue(move |mut sioul| {
1403            sioul.as_mut().set_health_view(QString::from(&view));
1404            sioul.as_mut().set_missed_view(QString::from(&missed));
1405            sioul.as_mut().set_needs_view(QString::from(&needs));
1406        });
1407    });
1408}
1409
1410// ---------------------------------------------------------------- phone alarms
1411
1412/// How far ahead the coming doses are given to Android's alarm clock.
1413const ALARMS_AHEAD_HOURS: i64 = 48;
1414
1415/// A dose as a reminder names it: "Levothyroxine · 75 µg".
1416fn named(dose: &sioul_core::health::Dose) -> String {
1417    if dose.dose.is_empty() { dose.name.clone() } else { format!("{} · {}", dose.name, dose.dose) }
1418}
1419
1420/// The coming doses, as Android's alarm clock is given them (`alarms::schedule`):
1421/// [{key, at (Unix milliseconds), title}], from half an hour ago to two days
1422/// ahead, those marked or reminded left out. A record that does not read
1423/// leaves none out: the decision at their time reads it again.
1424pub(crate) fn alarms_coming() -> String {
1425    let health = load();
1426    let state = HealthState::read(&HealthState::default_path()).unwrap_or_default();
1427    let now = Zoned::now();
1428    let start = now.checked_sub(Span::new().minutes(GRACE_MINUTES)).unwrap_or_else(|_| now.clone());
1429    let end = now.checked_add(Span::new().hours(ALARMS_AHEAD_HOURS)).unwrap_or_else(|_| now.clone());
1430    let rows: Vec<serde_json::Value> = health
1431        .doses(&start, &end)
1432        .into_iter()
1433        .filter(|d| !state.taken.contains_key(&d.key) && !state.not_taken.contains_key(&d.key) && !state.reminded.contains_key(&d.key))
1434        .map(|d| serde_json::json!({ "key": d.key, "at": d.at.timestamp().as_millisecond(), "title": named(&d) }))
1435        .collect();
1436    serde_json::Value::Array(rows).to_string()
1437}
1438
1439/// The doses of the last day marked taken or not taken: their reminders, if
1440/// shown on a phone, are taken away (`alarms::schedule`).
1441pub(crate) fn marked_lately() -> Vec<String> {
1442    let Ok(state) = HealthState::read(&HealthState::default_path()) else { return Vec::new() };
1443    let since = Timestamp::now().as_second() - 26 * 3600;
1444    state.taken.keys().chain(state.not_taken.keys()).filter(|key| due_of(key).is_some_and(|due| due >= since)).cloned().collect()
1445}
1446
1447/// How long a reminder shown on a phone is checked again (every quarter of an
1448/// hour, the others' news read first), so that a dose marked on another device
1449/// takes it away: three hours after the dose's time.
1450const SHOWN_WATCH: i64 = 3 * 3600;
1451const SHOWN_EVERY: i64 = 15 * 60;
1452
1453/// At a dose's time, Android's alarm asks what to say (`alarms`), maybe with
1454/// nothing on the screen. What your other devices marked is read first
1455/// (`share::exchange_here`: Sioul's own pull from the server while it works,
1456/// else the sync app asked to bring it; an exchange without notes and papers,
1457/// which never holds a dose back); then, as the window would: taken, not taken
1458/// said, or reminded already: nothing; kept by another device you use: its
1459/// turn, asked again after the wait for news; not known: after that wait,
1460/// reminded with the doubt said first; else the dose. Recorded `reminded`
1461/// only when shown, so that another device still reminds what was not.
1462/// JSON {show, title, body, again_at (Unix ms, 0: never)}.
1463pub(crate) fn alarm_decide(key: &str) -> String {
1464    let answer = |show: bool, title: &str, body: &str, again: i64| serde_json::json!({ "show": show, "title": title, "body": body, "again_at": again * 1000, "taken": tr().text("health-taken", None) }).to_string();
1465    let _ = crate::share::exchange_here(true);
1466    // None of the others' news read since this alarm began (its pull and the
1467    // sync app bringing nothing): what was read before is said in doubt.
1468    let missed = crate::share::news_missed();
1469    let health = load();
1470    let now = Zoned::now();
1471    let stamp = now.timestamp().as_second();
1472    let _held = state_held();
1473    let state = record();
1474    let start = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1475    let end = now.checked_add(Span::new().hours(ALARMS_AHEAD_HOURS)).unwrap_or_else(|_| now.clone());
1476    // The medicine taken out, or its doses moved: this one is no more, nor its reminder.
1477    let Some(dose) = health.doses(&start, &end).into_iter().find(|d| d.key == key) else {
1478        crate::alarms::remove_reminder(key);
1479        return answer(false, "", "", 0);
1480    };
1481    let due = dose.at.timestamp().as_second();
1482    // Marked, here or on another device: its reminder, if shown, goes.
1483    if state.taken.contains_key(key) || state.not_taken.contains_key(key) {
1484        crate::alarms::remove_reminder(key);
1485        return answer(false, "", "", 0);
1486    }
1487    // Reminded already (here, still shown, or on another device): looked at
1488    // again a while, so that a mark made elsewhere takes the reminder away.
1489    if state.reminded.contains_key(key) || SENT.lock().is_ok_and(|sent| sent.contains(key)) {
1490        let again = if stamp < due + SHOWN_WATCH { stamp + SHOWN_EVERY } else { 0 };
1491        return answer(false, "", "", again);
1492    }
1493    // Too early (moved later): at its time.
1494    if due > stamp + 60 {
1495        return answer(false, "", "", due);
1496    }
1497    // Asleep, "Doses during sleep: stay silent" chosen: asked again at waking.
1498    if crate::hours::doses_silent()
1499        && let Some(waking) = crate::hours::waking()
1500    {
1501        return answer(false, "", "", waking);
1502    }
1503    // News waited for from the dose's time; held by sleep or a pause, from
1504    // waking: a computer keeping the doses reminds it then too.
1505    let since = news_from(due, crate::hours::woken());
1506    let waiting = stamp < since + WAIT_FOR_NEWS;
1507    let keeper = crate::share::looked("health", sioul_sync::lease::Rule::FollowsYou);
1508    if !keeper.mine && waiting {
1509        return answer(false, "", "", since + WAIT_FOR_NEWS);
1510    }
1511    let knowledge = know();
1512    // What the other devices did to remind it (docs/health.md, "What became of
1513    // a reminder"): shown somewhere, a reminder (this alarm silent); this
1514    // alarm plain only when each other device that could have reminded it is
1515    // known, by its own word, not to have; else why not, said first.
1516    let told_now = told();
1517    let me = this_device();
1518    // A computer's "closed" taken as its word only after this alarm's own pull went through (C1).
1519    let pulled = missed.is_none() && PULLED.load(std::sync::atomic::Ordering::Relaxed);
1520    let first = match sioul_core::dose_reminders::others(key, since, stamp, &me, true, told_now.as_ref(), &knowledge.peers, pulled) {
1521        Others::Reminded => {
1522            let again = if stamp < due + SHOWN_WATCH { stamp + SHOWN_EVERY } else { 0 };
1523            return answer(false, "", "", again);
1524        }
1525        Others::First => None,
1526        Others::Doubt(elsewhere) => {
1527            // Kept here, the phone in use at the dose's time: the computers'
1528            // words that they leave it to the phone come within a minute or
1529            // two; waited for that long.
1530            if keeper.mine && elsewhere.iter().all(|e| matches!(e, Elsewhere::Unknown { .. })) && stamp < since + FIRST_WORD {
1531                return answer(false, "", "", since + FIRST_WORD);
1532            }
1533            Some(elsewhere_said(&elsewhere, since, tr()))
1534        }
1535    };
1536    let doubt = doubt_unread_with(&knowledge, due, since, stamp, missed.as_ref(), first.as_deref());
1537    if first.is_none() && !doubt.is_empty() && waiting {
1538        let known = sioul_core::health::known_from_at(due, since, stamp + 1, since + WAIT_FOR_NEWS, knowledge.record_lost, &knowledge.peers);
1539        return answer(false, "", "", alarm_again(since, stamp, known));
1540    }
1541    let named = named(&dose);
1542    let (title, body) = if doubt.is_empty() { (named, dose.at.strftime("%H:%M").to_string()) } else { (say("dose-check-title", &[("dose", named)]), format!("{}. {doubt}", dose.at.strftime("%H:%M"))) };
1543    if let Ok(mut sent) = SENT.lock() {
1544        sent.insert(key.to_string());
1545    }
1546    // A session of its own, Sioul not on the screen (`devices::receiver`): up,
1547    // the reminder and the dose's record (the canary) noted, sent, down. Your
1548    // other devices know it was reminded: they do not remind it again.
1549    crate::devices::receiver(move || {
1550        let _ = change(|record| {
1551            record.reminded.insert(key.to_string(), stamp);
1552        });
1553        // Its own word, before it shows: the others read it reminded.
1554        tell_mine(key, |mine| {
1555            mine.maybe = stamp;
1556            mine.shown = stamp;
1557        });
1558        open_canaries(&[key.to_string()], stamp);
1559        drop(_held);
1560        let _ = crate::share::exchange_here(false);
1561    });
1562    // Shown: looked at again while it may still be shown (a mark elsewhere takes it away).
1563    answer(true, &title, &body, stamp + SHOWN_EVERY)
1564}
1565
1566/// "Taken", pressed on the phone's reminder. More than half an hour late, when
1567/// it was taken is asked in the window instead (`open`). JSON {done, open, line}.
1568pub(crate) fn alarm_taken(key: &str) -> String {
1569    if is_late(key) {
1570        return serde_json::json!({ "done": false, "open": true, "line": tr().text("dose-alarm-late", None) }).to_string();
1571    }
1572    // A session of its own, Sioul not on the screen (`devices::receiver`): up,
1573    // the answer noted, sent, down; your other devices show it taken.
1574    crate::devices::receiver(|| {
1575        let problem = set_taken(key, true);
1576        if !problem.is_empty() {
1577            return serde_json::json!({ "done": false, "open": true, "line": problem }).to_string();
1578        }
1579        let _ = crate::share::exchange_here(false);
1580        let line = say("health-taken-at", &[("time", Zoned::now().strftime("%H:%M").to_string())]);
1581        serde_json::json!({ "done": true, "open": false, "line": line }).to_string()
1582    })
1583}
1584
1585// ---------------------------------------------------------------- a phone's steps while a dose is due
1586
1587/// Whether a dose's time has come and it is not marked yet, as the Porch
1588/// still asks about it (until the day's end, twelve hours at most): while one
1589/// is and another device runs Sioul, a phone in the background keeps its
1590/// two-minute steps, while you sleep too, so that a mark made there comes
1591/// within them, not at its next slow look (docs/health.md, "Knowing";
1592/// `steps::keeps_close`). Read only, nothing repaired here; a record that does
1593/// not read counts every dose whose time has come as open, never as none, and
1594/// only within the same hours: never before a dose's time nor past its day's
1595/// end (review of battery part C, G4).
1596pub(crate) fn dose_open_now() -> bool {
1597    let health = load();
1598    if health.medicines.is_empty() {
1599        return false;
1600    }
1601    let read = HealthState::read(&HealthState::default_path()).ok().zip(DoseRecords::read(&DoseRecords::default_path()).ok());
1602    dose_open(&health, read.as_ref().map(|(state, records)| (state, records)), &Zoned::now())
1603}
1604
1605/// `dose_open_now`, from what was read (`records`: the dose record and the
1606/// records come from the other devices; none when one does not read): a dose
1607/// of today's, its time come (twelve hours back at most), answered nowhere
1608/// this device knows; the records unread, any such dose.
1609fn dose_open(health: &Health, records: Option<(&HealthState, &DoseRecords)>, now: &Zoned) -> bool {
1610    let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
1611    let back = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1612    let start = if back > midnight { back } else { midnight };
1613    let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1614    let mut come = health.doses(&start, &end).into_iter();
1615    match records {
1616        Some((state, records)) => come.any(|d| sioul_core::doses::answered(&d.key, state, records) == Answered::Open),
1617        None => come.next().is_some(),
1618    }
1619}
1620
1621// ---------------------------------------------------------------- the home screen's card
1622
1623/// Today's doses for the card on a phone's home screen (`homecard`): those
1624/// due and not marked, from their time on, as the Porch has them
1625/// (`due_today`: until marked, the day's end, or twelve hours after their
1626/// time), and those still to come today; each with until when it is known
1627/// not marked on another device, the Porch's rule given ahead
1628/// (`doubts_now`: every other device heard within five minutes), 0 when it
1629/// is not known from its time on. A record that does not read is never read
1630/// as empty: every dose is then not known. Whether doses show while you
1631/// sleep or pause is the card's frames' to say.
1632pub(crate) fn card_doses(now: &Zoned) -> Vec<crate::homecard::Dose> {
1633    let health = load();
1634    if health.medicines.is_empty() {
1635        return Vec::new();
1636    }
1637    let read = HealthState::read(&HealthState::default_path());
1638    let readable = read.is_ok();
1639    doses_for_card(&health, &read.unwrap_or_default(), &records(), readable, &know(), now)
1640}
1641
1642/// `card_doses`, from what was read. A dose answered differently on two
1643/// devices (`doses::answered`: taken on one, said not taken on another) is
1644/// on the card too, never known: "check before taking it", as the Porch asks you to choose.
1645fn doses_for_card(health: &Health, state: &HealthState, records: &DoseRecords, readable: bool, knowledge: &Knowledge, now: &Zoned) -> Vec<crate::homecard::Dose> {
1646    let stamp = now.timestamp().as_second();
1647    let midnight_after = |at: &Zoned| at.date().tomorrow().ok().and_then(|d| d.to_zoned(at.time_zone().clone()).ok());
1648    let Some(tonight) = midnight_after(now) else { return Vec::new() };
1649    let later = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1650    let coming = health.doses(&later, &tonight).into_iter().filter(|d| !state.taken.contains_key(&d.key) && !state.not_taken.contains_key(&d.key));
1651    // Answered in the records, their marks not here (yet): answered all the same; never on the card twice.
1652    let mut out: Vec<crate::homecard::Dose> = state
1653        .due_today(health, now, MISSED_HOURS, GRACE_MINUTES)
1654        .into_iter()
1655        .chain(coming)
1656        .filter(|d| sioul_core::doses::answered(&d.key, state, records) == Answered::Open)
1657        .flat_map(|d| {
1658            let at = d.at.timestamp().as_second();
1659            let end = midnight_after(&d.at).map_or(at + 86_400, |m| m.timestamp().as_second());
1660            let until = (at + MISSED_HOURS * 3600).min(end);
1661            let start = stamp.max(at);
1662            // Not known at once (a device closed before it may open around it,
1663            // `known_from`): "check" from its time until it can be, then the line.
1664            let pieces = match readable.then(|| sioul_core::health::known_from(at, start, until, knowledge.record_lost, &knowledge.peers)).flatten() {
1665                None => vec![(at, until, 0)],
1666                Some(known) if known <= start => vec![(at, until, card_known_until(knowledge, at, start, until))],
1667                Some(known) => vec![(at, known, 0), (known, until, card_known_until(knowledge, at, known, until))],
1668            };
1669            let (name, time) = (named(&d), d.at.strftime("%H:%M").to_string());
1670            pieces.into_iter().map(move |(from, until, known_until)| crate::homecard::Dose { name: name.clone(), time: time.clone(), at: from, until, known_until })
1671        })
1672        .collect();
1673    // Answered differently, as far back as today's doses show: never known, until one answer is chosen.
1674    let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
1675    let back = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1676    for d in health.doses(if back > midnight { &back } else { &midnight }, &later) {
1677        if matches!(sioul_core::doses::answered(&d.key, state, records), Answered::Differ(_)) {
1678            let at = d.at.timestamp().as_second();
1679            let end = midnight_after(&d.at).map_or(at + 86_400, |m| m.timestamp().as_second());
1680            out.push(crate::homecard::Dose { name: named(&d), time: d.at.strftime("%H:%M").to_string(), at, until: (at + MISSED_HOURS * 3600).min(end), known_until: 0 });
1681        }
1682    }
1683    out.sort_by_key(|d| d.at);
1684    out
1685}
1686
1687/// Until when a dose due at `due` stays known not marked elsewhere, seen
1688/// from `from` (`doubts_now`: each other device fresh five minutes after it
1689/// was last heard in full): `until` when nothing ends it before; 0 when it is
1690/// not known at `from`.
1691fn card_known_until(knowledge: &Knowledge, due: i64, from: i64, until: i64) -> i64 {
1692    sioul_core::health::known_until(due, from, until, knowledge.record_lost, &knowledge.peers)
1693}
1694
1695// ---------------------------------------------------------------- knowing
1696
1697/// How long a dose not known here waits for news from your other devices
1698/// before it is reminded all the same, the doubt said; a phone's alarm, when
1699/// a computer keeps the doses, waits as long for the computer's reminder
1700/// (`sioul_core::health::ALARM_AFTER`, one moment for both).
1701const WAIT_FOR_NEWS: i64 = sioul_core::health::ALARM_AFTER;
1702
1703/// Until when after a dose's time this computer may show its reminder late
1704/// while a phone shares the doses (a try after the first that did not go
1705/// out, a dose an earlier Sioul here tried, a reminder waiting for news):
1706/// three minutes before the phone's alarm, so that what it shows reaches the
1707/// phone first (its exchange each minute, the server, the phone's own pull at
1708/// its alarm); later, the phone's alarm and the question on the past take it
1709/// up (review of battery part C, the fourth review's H2).
1710const LATE_UNTIL: i64 = WAIT_FOR_NEWS - 3 * 60;
1711
1712/// How long a phone's alarm keeping the doses itself (the phone in use at the
1713/// dose's time) waits for the computers' words that they leave the dose to
1714/// it (`dose_reminders::Tried::quiet`), before it reminds with the doubt.
1715const FIRST_WORD: i64 = 2 * 60;
1716
1717/// When a dose's alarm in doubt within the wait for news asks again: as soon
1718/// as it may be known (`known`: a device closed before the dose, once the
1719/// news of an opening had the time to come), a minute apart at least; known
1720/// already by what was read (`known` now), in doubt only because no news came
1721/// since the alarm began, nothing tells when news comes: at the end of the
1722/// wait, as for a doubt that has no end in view, then reminded with the doubt
1723/// said if none came (until 10 October 2026 it asked each minute, a pull each
1724/// time: R9 of that day's review).
1725fn alarm_again(since: i64, stamp: i64, known: Option<i64>) -> i64 {
1726    match known {
1727        Some(at) if at > stamp + 1 => at.max(stamp + 60).min(since + WAIT_FOR_NEWS),
1728        _ => since + WAIT_FOR_NEWS,
1729    }
1730}
1731
1732/// From when a phone's alarm waits for news of a dose due at `due`: its
1733/// time; the dose due while sleep or a pause held it (`hours::woken`: when it
1734/// began and ended), the waking, when a computer keeping the doses reminds
1735/// it too, so that the two never remind it together, both plainly (review
1736/// of battery part C, the fourth round).
1737fn news_from(due: i64, woken: Option<(i64, i64)>) -> i64 {
1738    woken.filter(|(from, _)| *from <= due).map_or(due, |(_, woke)| woke.max(due))
1739}
1740
1741/// The doses' record found broken or gone here, kept on this computer only
1742/// (`$XDG_STATE_HOME/sioul/health-doubt.toml`): the doses due before stay
1743/// not known, for a day.
1744#[derive(Debug, Default, Serialize, Deserialize)]
1745struct RecordDoubt {
1746    since: i64,
1747    /// Where the broken record was kept aside; "" when it was gone.
1748    #[serde(default)]
1749    aside: String,
1750}
1751
1752fn record_doubt_path() -> std::path::PathBuf {
1753    sioul_core::config::state_dir().join("health-doubt.toml")
1754}
1755
1756fn record_doubt() -> Option<i64> {
1757    let doubt: RecordDoubt = toml::from_str(&std::fs::read_to_string(record_doubt_path()).ok()?).ok()?;
1758    Some(doubt.since)
1759}
1760
1761fn write_record_doubt(since: i64, aside: &str) {
1762    let doubt = RecordDoubt { since, aside: aside.to_string() };
1763    if let Ok(text) = toml::to_string(&doubt) {
1764        let _ = std::fs::write(record_doubt_path(), text);
1765    }
1766}
1767
1768/// The doses' record. One that cannot be trusted is never read as empty: it
1769/// is repaired (`repair`), and the doses due before are said not known.
1770fn record() -> HealthState {
1771    let path = HealthState::default_path();
1772    match HealthState::read(&path) {
1773        Ok(state) => state,
1774        Err(_) => {
1775            repair();
1776            HealthState::read(&path).unwrap_or_default()
1777        }
1778    }
1779}
1780
1781/// A record that cannot be trusted: nothing it held may be taken out on your
1782/// other devices, so the sharing forgets it first and reads it again from
1783/// every device's records at the next exchange; it is kept aside in the same
1784/// step, before any exchange can run, and a new one starts. Sharing failing
1785/// that, it is left as it is: never started again empty where the sharing
1786/// could take marks out elsewhere.
1787fn repair() {
1788    let now = Timestamp::now().as_second();
1789    let path = HealthState::default_path();
1790    let set_aside = || HealthState::set_aside(&path, now);
1791    let aside = if crate::share::on() {
1792        match crate::share::rebuild_health_record(set_aside) {
1793            Ok(aside) => aside,
1794            Err(_) => {
1795                write_record_doubt(now, "");
1796                return;
1797            }
1798        }
1799    } else {
1800        set_aside()
1801    };
1802    write_record_doubt(now, &aside.ok().flatten().map(|p| p.display().to_string()).unwrap_or_default());
1803}
1804
1805/// The record changed by `change`, under its lock; one that cannot be trusted
1806/// is repaired first, then changed. Returns what went wrong, else "".
1807fn change(change: impl Fn(&mut HealthState)) -> String {
1808    let path = HealthState::default_path();
1809    let now = Timestamp::now().as_second();
1810    // What changed is owed to your other devices until an export reads it,
1811    // said once written (`devices::recorded`); a minute that changed nothing owes nothing.
1812    let changed = std::sync::atomic::AtomicBool::new(false);
1813    let changing = |state: &mut HealthState| {
1814        let before = state.clone();
1815        change(state);
1816        if *state != before {
1817            changed.store(true, std::sync::atomic::Ordering::Relaxed);
1818        }
1819    };
1820    let problem = match HealthState::update(&path, now, &changing) {
1821        Ok(()) => {
1822            // A dose marked: a phone's alarm for it goes (`alarms`).
1823            crate::alarms::schedule();
1824            String::new()
1825        }
1826        Err(Problem::Unsound(_)) => {
1827            repair();
1828            match HealthState::update(&path, now, &changing) {
1829                Ok(()) => String::new(),
1830                Err(_) => tr().text("dose-record-broken", None),
1831            }
1832        }
1833        Err(Problem::Write(e)) => e,
1834    };
1835    if changed.load(std::sync::atomic::Ordering::Relaxed) {
1836        crate::devices::recorded();
1837    }
1838    problem
1839}
1840
1841/// The doses' records (`sioul_core::doses`: each dose that fell due, and the
1842/// answers your devices captured), as `record` reads the marks: records that
1843/// cannot be trusted are never read as empty; they are repaired, and the
1844/// doses due before are said not known for a day.
1845fn records() -> DoseRecords {
1846    let path = DoseRecords::default_path();
1847    match DoseRecords::read(&path) {
1848        Ok(records) => records,
1849        Err(_) => {
1850            repair_records();
1851            DoseRecords::read(&path).unwrap_or_default()
1852        }
1853    }
1854}
1855
1856/// Records that cannot be trusted, as `repair` does the marks: the sharing
1857/// first forgets holding them and reads them again from every device's
1858/// records; they are kept aside in the same step, and new ones start.
1859fn repair_records() {
1860    let now = Timestamp::now().as_second();
1861    let path = DoseRecords::default_path();
1862    let set_aside = || DoseRecords::set_aside(&path, now);
1863    let aside = if crate::share::on() {
1864        match crate::share::rebuild_dose_records(set_aside) {
1865            Ok(aside) => aside,
1866            Err(_) => {
1867                write_record_doubt(now, "");
1868                return;
1869            }
1870        }
1871    } else {
1872        set_aside()
1873    };
1874    write_record_doubt(now, &aside.ok().flatten().map(|p| p.display().to_string()).unwrap_or_default());
1875}
1876
1877/// The records changed by `change`, under their lock; ones that cannot be
1878/// trusted repaired first, then changed. Returns what went wrong, else "".
1879fn change_records(change: impl Fn(&mut DoseRecords)) -> String {
1880    let path = DoseRecords::default_path();
1881    let now = Timestamp::now().as_second();
1882    // Owed to your other devices until an export reads it, as the marks (`change`).
1883    let changed = std::sync::atomic::AtomicBool::new(false);
1884    let changing = |records: &mut DoseRecords| {
1885        let before = records.clone();
1886        change(records);
1887        if *records != before {
1888            changed.store(true, std::sync::atomic::Ordering::Relaxed);
1889        }
1890    };
1891    let problem = match DoseRecords::update(&path, now, &changing) {
1892        Ok(()) => String::new(),
1893        Err(Problem::Unsound(_)) => {
1894            repair_records();
1895            match DoseRecords::update(&path, now, &changing) {
1896                Ok(()) => String::new(),
1897                Err(_) => tr().text("dose-record-broken", None),
1898            }
1899        }
1900        Err(Problem::Write(e)) => e,
1901    };
1902    if changed.load(std::sync::atomic::Ordering::Relaxed) {
1903        crate::devices::recorded();
1904    }
1905    problem
1906}
1907
1908/// Your answer for a dose, captured here (its time `at`), in the doses'
1909/// records, beside the marks: `settle` takes every other answer saying
1910/// otherwise out (you chose, seeing them). Returns what went wrong, else "".
1911fn record_answer(key: &str, state: &str, at: i64, settle: bool) -> String {
1912    let me = this_device();
1913    if me.is_empty() {
1914        return String::new();
1915    }
1916    let answer = Answer { state: state.to_string(), at, noted: Timestamp::now().as_second(), name: crate::devices::name(), kind: crate::devices::kind().to_string() };
1917    change_records(|records| if settle { records.choose(key, &me, answer.clone()) } else { records.answer(key, &me, answer.clone()) })
1918}
1919
1920/// The doses due in the last hours (as far back as a dose is asked about),
1921/// answered nowhere this device knows: their records opened, not taken yet
1922/// (the canary), where no device opened them yet.
1923fn open_due(health: &Health, state: &HealthState, now: &Zoned) {
1924    let start = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1925    let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1926    let keys: Vec<String> = health.doses(&start, &end).into_iter().map(|d| d.key).filter(|key| !state.taken.contains_key(key) && !state.not_taken.contains_key(key)).collect();
1927    open_canaries(&keys, now.timestamp().as_second());
1928}
1929
1930/// Each of `keys` opened here, not taken yet, unless a device opened or answered it already.
1931fn open_canaries(keys: &[String], now: i64) {
1932    let me = this_device();
1933    if keys.is_empty() || me.is_empty() {
1934        return;
1935    }
1936    // Records that do not read are repaired where they are read (`records`), not here.
1937    let Ok(records) = DoseRecords::read(&DoseRecords::default_path()) else { return };
1938    if keys.iter().all(|key| records.dose.get(key).is_some_and(|r| !r.opened.is_empty() || !r.answer.is_empty())) {
1939        return;
1940    }
1941    let _ = change_records(|records| {
1942        for key in keys {
1943            records.open(key, &me, now);
1944        }
1945    });
1946}
1947
1948/// This device's id in the sharing ("" before it has one).
1949fn this_device() -> String {
1950    crate::share::vault().map(|(id, _)| id).unwrap_or_default()
1951}
1952
1953/// What this computer saw of your other devices, for the doses: kept here
1954/// only (`$XDG_STATE_HOME/sioul/share/peers.json`), never shared.
1955#[derive(Debug, Default, Serialize, Deserialize)]
1956struct Peers {
1957    #[serde(default)]
1958    peers: std::collections::BTreeMap<String, PeerSeen>,
1959    /// When this device last looked (its clock): an opening seen now is timed
1960    /// only when it looked a moment before, else its own absence would count.
1961    #[serde(default)]
1962    looked: i64,
1963}
1964
1965#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1966struct PeerSeen {
1967    peer: Peer,
1968    /// Its last claim seen here: when it renewed it (its clock).
1969    #[serde(default)]
1970    renewed: i64,
1971    /// When this computer last looked (its clock): news is timed only while it watches.
1972    #[serde(default)]
1973    looked: i64,
1974    /// How late its last claims came here, in seconds.
1975    #[serde(default)]
1976    delays: Vec<i64>,
1977    /// Its newest entry in the devices' registry read here, by its own times
1978    /// (`devices::written`; `peer.seen`: when it came): never replaced by an
1979    /// older copy put back, nor taken from a clock more than `FUTURE` ahead.
1980    #[serde(default)]
1981    entry: Option<sioul_sync::devices::Entry>,
1982    /// The words (`Entry::acked`) of the copy of its entry read now, when one
1983    /// reads and is not set aside (`Peer::set_aside`): a phone's word stands
1984    /// here only where this copy says it as well as the newest one, so that a
1985    /// copy that is no news may take a word away, never give one (the final
1986    /// review before 0.0.6, F1H). Not kept from one reading to the next.
1987    #[serde(default, skip_serializing_if = "Option::is_none")]
1988    acked_now: Option<std::collections::BTreeMap<String, i64>>,
1989    /// Its export's date (its clock) less when it was seen here (this one's),
1990    /// the last sixty: how far its clock is ahead, at least, is the largest.
1991    #[serde(default)]
1992    aheads: Vec<i64>,
1993    /// You said it is off: what it had said then. Counted again once it says anything newer.
1994    #[serde(default)]
1995    off: Option<Mark>,
1996    /// You forgot it, silent: not listed until it says anything newer.
1997    #[serde(default)]
1998    forgotten: Option<Mark>,
1999    /// How late the news of its last openings came here, in seconds
2000    /// (`Peer::wake`: the longest), the last `WAKES`.
2001    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2002    wakes: Vec<i64>,
2003    /// Since when its claim on the doses ran, as last seen: an older Sioul's
2004    /// openings, timed as an entry's start is.
2005    #[serde(default, skip_serializing_if = "is_zero")]
2006    since: i64,
2007}
2008
2009fn is_zero(n: &i64) -> bool {
2010    *n == 0
2011}
2012
2013/// How far ahead of this device's clock another device's times may run and
2014/// still be taken (ten minutes: a clock left to drift; one set wrong by hours
2015/// or a year is not): beyond it, its copy is no news and is set aside
2016/// (`Peer::set_aside`): nothing it says counts, nor a word it gives, and its
2017/// doubt says its clock runs ahead (the ninth review's F1; the final review's
2018/// F1H).
2019const FUTURE: i64 = 10 * 60;
2020
2021/// The latest of an entry's times (its device's clock).
2022fn latest_time(entry: &sioul_sync::devices::Entry) -> i64 {
2023    entry.started.max(entry.closed).max(entry.imported).max(entry.exported)
2024}
2025
2026/// Whether a copy of an entry is dated more than `FUTURE` ahead of `now`.
2027fn from_the_future(entry: &sioul_sync::devices::Entry, now: i64) -> bool {
2028    latest_time(entry) > now + FUTURE
2029}
2030
2031/// How many of a device's openings are kept to time the next: an opening
2032/// whose news came late once (a phone offline when picked up) holds the wait
2033/// for this many more, then goes.
2034const WAKES: usize = 10;
2035
2036/// The longest an opening's news counts as late when this device cannot
2037/// tell whether its own news came in time (a sync app carries the folder:
2038/// paused, or stopped, unseen from here): a longer wait is as likely this
2039/// device's own outage, and would hold the doses of a closed device in doubt
2040/// that long, for its next `WAKES` openings (review of 10 October 2026, R8).
2041const UNSURE_WAKE: i64 = 60 * 60;
2042
2043/// How late the news of a device's opening came here, kept (`PeerSeen::wakes`),
2044/// when this device's own news was flowing then (`news`: its own pull went
2045/// through lately; none when a sync app alone carries the folder, the delay
2046/// then counted up to `UNSURE_WAKE`). Its own pull stale or failing: the
2047/// delay is its own outage, not timed.
2048fn note_wake(seen: &mut PeerSeen, delay: i64, news: Option<bool>) {
2049    let delay = match news {
2050        Some(false) => return,
2051        Some(true) => delay,
2052        None => delay.min(UNSURE_WAKE),
2053    };
2054    seen.wakes.push(delay.max(0));
2055    let keep = seen.wakes.len().saturating_sub(WAKES);
2056    seen.wakes.drain(..keep);
2057}
2058
2059/// What a device had said when you said it was off, or forgot it.
2060#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2061struct Mark {
2062    #[serde(default)]
2063    started: i64,
2064    #[serde(default)]
2065    exported: i64,
2066    #[serde(default)]
2067    renewed: i64,
2068    /// When you said so (this device's clock).
2069    #[serde(default)]
2070    at: i64,
2071}
2072
2073fn peers_path() -> std::path::PathBuf {
2074    sioul_core::config::state_dir().join("share").join("peers.json")
2075}
2076
2077/// What is known here of the doses marked elsewhere: this computer's record,
2078/// and each other device sharing with it (`sioul_core::health::doubts`).
2079pub(crate) struct Knowledge {
2080    record_lost: Option<i64>,
2081    peers: Vec<Peer>,
2082}
2083
2084/// One look at a time at the others' claims.
2085static KNOWING: std::sync::Mutex<()> = std::sync::Mutex::new(());
2086
2087/// Whether this device's own pull from the server went through lately, as
2088/// `know` last saw it (`share::Others::news`): a phone's alarm takes a
2089/// computer's "closed" as its word that it did not remind a dose only then
2090/// (`dose_reminders::others`; the sixth review's C1).
2091static PULLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2092
2093/// What this computer knows now: the others' claims on the doses read (each
2094/// says how far it wrote its records, and whether it closed), set against
2095/// what was read of their records here.
2096fn know() -> Knowledge {
2097    let now = Timestamp::now().as_second();
2098    let record_lost = record_doubt();
2099    let Some(others) = crate::share::others_on_health() else {
2100        PULLED.store(false, std::sync::atomic::Ordering::Relaxed);
2101        return Knowledge { record_lost, peers: Vec::new() };
2102    };
2103    PULLED.store(others.news == Some(true), std::sync::atomic::Ordering::Relaxed);
2104    let _held = KNOWING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2105    // This device's own entry: its word on each one's opening, the devices it replaces.
2106    let own = sioul_sync::devices::own(&sioul_sync::devices::own_path(&sioul_core::config::state_dir()));
2107    // Read, learned, written under its lock: a phone's background service learns too, in its own process.
2108    let kept = sioul_core::filelock::with_lock(&peers_path(), || {
2109        let mut kept = load_peers();
2110        learn_with(&mut kept, &others.claims, &others.entries, &others.heard, others.news, now);
2111        let replaced = replacements(&kept, &others.entries, &own);
2112        apply_replaced(&mut kept, &replaced);
2113        save_peers(&kept);
2114        kept
2115    });
2116    // Each a phone or not as it says: what a phone does (its alarm) is read
2117    // from it; two phones or more are each named by their names, never both
2118    // "the phone", in the words only (`sioul_core::health::Named`; review of
2119    // battery part C, the fifth review's C1).
2120    let mut peers: Vec<Peer> = kept.peers.values().map(|s| s.peer.clone()).collect();
2121    // This device's own word on each one's opening, as its sealed entry says it (`acknowledged_openings`).
2122    for peer in &mut peers {
2123        peer.acknowledged = own.acked.get(&peer.id).copied().unwrap_or(0);
2124    }
2125    // A device sharing through the folder this last week whose claim and entry
2126    // do not read here (its seal not arrived yet, a damaged file): a peer all
2127    // the same, never heard from, so that its doses are said not known.
2128    let silent = sioul_core::health::SILENT_DAYS * 86_400;
2129    for other in others.others.iter().filter(|o| !kept.peers.contains_key(&o.id) && now - o.heard < silent) {
2130        peers.push(Peer { id: other.id.clone(), name: tr().text("share-other-device", None), heard: other.heard, ..Peer::default() });
2131    }
2132    let unread: Vec<String> = others.unread.iter().filter(|id| !kept.peers.contains_key(*id) && !peers.iter().any(|p| p.id == **id)).cloned().collect();
2133    for id in unread {
2134        peers.push(Peer { id, name: tr().text("share-other-device", None), heard: now, ..Peer::default() });
2135    }
2136    Knowledge { record_lost, peers }
2137}
2138
2139/// Every replacement said (`devices::Entry::replaces`): by the entries read
2140/// now, by the newest copy of each kept here (an older copy put back never
2141/// takes one away), and by this device's own entry (a phone that joined in
2142/// another's place).
2143fn replacements(kept: &Peers, entries: &[sioul_sync::devices::Entry], own: &sioul_sync::devices::Entry) -> Vec<sioul_sync::devices::Replaced> {
2144    let newest = kept.peers.values().filter_map(|seen| seen.entry.as_ref());
2145    entries.iter().chain(newest).chain(std::iter::once(own)).flat_map(|e| e.replaces.iter().cloned()).collect()
2146}
2147
2148/// The devices another device says it replaces (`devices::Entry::replaces`;
2149/// this device's own entry's too), counted as forgotten here (`Peer::off`,
2150/// `Peer::forgotten`: no word owed on this computer's openings, no doses
2151/// waited for from them) while they said nothing newer than the replacement
2152/// says: a new start, a later time of their entry, a claim renewed since. A
2153/// phone said replaced wrongly counts again by itself the moment it speaks.
2154/// Read anew at each look, never kept as a mark.
2155fn apply_replaced(kept: &mut Peers, replaced: &[sioul_sync::devices::Replaced]) {
2156    for r in replaced {
2157        let Some(seen) = kept.peers.get_mut(&r.id) else { continue };
2158        let latest = seen.entry.as_ref().map_or(0, latest_time).max(seen.renewed).max(seen.peer.heard).saturating_mul(1000);
2159        if r.holds(seen.entry.as_ref().map(|e| e.started), latest) {
2160            seen.peer.off = true;
2161            seen.peer.forgotten = true;
2162        }
2163    }
2164}
2165
2166/// What a replacement says of `id` now (`devices::Replaced`): its last start
2167/// as kept here, and the latest it was heard of, from its entry, its claims
2168/// and its records (`share::heard_of`), in milliseconds.
2169pub(crate) fn replaced_now(id: &str) -> sioul_sync::devices::Replaced {
2170    let _ = know();
2171    let kept = load_peers();
2172    let seen = kept.peers.get(id);
2173    let entry = seen.and_then(|s| s.entry.as_ref());
2174    let latest = entry.map_or(0, latest_time).max(seen.map_or(0, |s| s.renewed.max(s.peer.heard))).saturating_mul(1000);
2175    sioul_sync::devices::Replaced { id: id.to_string(), started: entry.map_or(0, |e| e.started), heard: latest.max(crate::share::heard_of(id)) }
2176}
2177
2178/// Each other device's newest entry read here (`learn_with`: an older copy
2179/// put back never replaces it), as `know` last kept them, of the devices
2180/// whose entry reads now only (`Peer::said`), and not set aside: one no
2181/// longer read, or dated far ahead, says nothing. Its words (`Entry::acked`)
2182/// only those the copy read now says too: a copy that is no news (an older
2183/// one, one written in the same second, one of a phone whose clock was set
2184/// back) may take a word away, never give one (the final review's F1H).
2185fn newest_entries() -> Vec<sioul_sync::devices::Entry> {
2186    newest_of(&load_peers())
2187}
2188
2189fn newest_of(kept: &Peers) -> Vec<sioul_sync::devices::Entry> {
2190    kept.peers
2191        .values()
2192        .filter(|seen| seen.peer.said.is_some() && !seen.peer.set_aside)
2193        .filter_map(|seen| {
2194            let now = seen.acked_now.as_ref()?;
2195            let mut entry = seen.entry.clone()?;
2196            entry.acked.retain(|computer, opening| now.get(computer) == Some(opening));
2197            Some(entry)
2198        })
2199        .collect()
2200}
2201
2202fn load_peers() -> Peers {
2203    std::fs::read_to_string(peers_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
2204}
2205
2206/// Written whole beside its place, then renamed over it (`share::write_atomically`):
2207/// a crash in mid-write never leaves half of it, which read as nothing would
2208/// lose the newest copies kept, the openings read and the off and forgotten
2209/// marks (final review of 15 October 2026).
2210fn save_peers(kept: &Peers) -> String {
2211    let written = serde_json::to_string(kept).map_err(|e| e.to_string()).and_then(|text| sioul_sync::share::write_atomically(&peers_path(), text.as_bytes()));
2212    written.err().unwrap_or_default()
2213}
2214
2215/// What the others' claims say, learned: each one's name, how late its news
2216/// comes, and until when everything it wrote is read here.
2217#[cfg(test)]
2218fn learn(kept: &mut Peers, claims: &[sioul_sync::lease::Claim], entries: &[sioul_sync::devices::Entry], heard: &sioul_sync::share::Heard, now: i64) {
2219    learn_with(kept, claims, entries, heard, None, now);
2220}
2221
2222/// The same, `news` saying whether this device's own news was flowing now
2223/// (`share::Others::news`), for timing the others' openings.
2224fn learn_with(kept: &mut Peers, claims: &[sioul_sync::lease::Claim], entries: &[sioul_sync::devices::Entry], heard: &sioul_sync::share::Heard, news: Option<bool>, now: i64) {
2225    // How late a device's opening comes is timed only while this device
2226    // looked a moment before (review of 5 October 2026, F21), and its own
2227    // news came in time (`note_wake`).
2228    let watching = kept.looked > 0 && now - kept.looked <= 2 * 60;
2229    kept.looked = now;
2230    for claim in claims {
2231        let seen = kept.peers.entry(claim.computer.clone()).or_default();
2232        seen.peer.id = claim.computer.clone();
2233        seen.peer.name = claim.name.clone();
2234        // Its claim begun again, open (an older Sioul's opening): how late its news came.
2235        if claim.since != seen.since {
2236            if seen.since > 0 && watching && !claim.closed {
2237                note_wake(seen, now - claim.since, news);
2238            }
2239            seen.since = claim.since;
2240        }
2241        if claim.renewed > seen.renewed {
2242            // How late its news comes, timed only while watching: a claim
2243            // found after this computer was closed is old, not late.
2244            if seen.renewed > 0 && now - seen.looked <= 2 * 60 {
2245                seen.delays.push((now - claim.renewed).max(0));
2246                let keep = seen.delays.len().saturating_sub(60);
2247                seen.delays.drain(..keep);
2248            }
2249            seen.renewed = claim.renewed;
2250        }
2251        seen.looked = now;
2252        seen.peer.delay = seen.delays.iter().copied().max();
2253        seen.peer.heard = seen.peer.heard.max(claim.renewed);
2254        // Renewed while open: it is open, whatever its entry last said (R1d).
2255        if !claim.closed {
2256            seen.peer.claimed = seen.peer.claimed.max(claim.renewed);
2257        }
2258        // Everything it wrote until its claim, read here: known until then,
2259        // never past this device's clock: a claim dated ahead (its clock
2260        // fast) by more than a minute is known not at all (review of 5
2261        // October 2026, F13), one a little ahead until now.
2262        if let Some(wrote) = claim.wrote
2263            && heard.complete(&claim.computer, wrote)
2264            && claim.renewed <= now + 60
2265            && claim.renewed.min(now) >= seen.peer.known_until
2266        {
2267            seen.peer.known_until = claim.renewed.min(now);
2268            seen.peer.closed = claim.closed;
2269        }
2270        seen.peer.broken = heard.broken.get(&claim.computer).copied();
2271    }
2272    // What each says of itself in the registry (`sioul_sync::devices`), read
2273    // now: only what reads now counts; a device whose entry no longer reads is
2274    // known by its claims, as an older Sioul is, never as it last said.
2275    for seen in kept.peers.values_mut() {
2276        seen.peer.said = None;
2277        seen.peer.complete = false;
2278        seen.peer.set_aside = false;
2279        seen.acked_now = None;
2280    }
2281    for entry in entries {
2282        let seen = kept.peers.entry(entry.id.clone()).or_default();
2283        seen.peer.id = entry.id.clone();
2284        if !entry.name.is_empty() {
2285            seen.peer.name = entry.name.clone();
2286        }
2287        // A copy kept from a clock far ahead already when it came (by a
2288        // Sioul before this rule), that clock since set right: dropped, so
2289        // that what it says now counts again (the ninth review's F1). One
2290        // that only looks ahead because this clock went back since is kept,
2291        // and so are the marks set against it: an older copy put back is no
2292        // news when this clock is set right (W1), and a device said off or
2293        // forgotten stays so (the final review's F1L).
2294        let came = seen.peer.seen;
2295        let ahead_when_kept = |at: i64| at > now + FUTURE && at > came + FUTURE;
2296        if seen.entry.as_ref().is_some_and(|last| ahead_when_kept(latest_time(last))) {
2297            seen.entry = None;
2298        }
2299        if ahead_when_kept(seen.peer.latest_opening) {
2300            seen.peer.latest_opening = 0;
2301        }
2302        // A copy dated far ahead of this clock is set aside: no news, no word,
2303        // said so. The words of the one read now, kept for `newest_of`.
2304        let aside = from_the_future(entry, now);
2305        seen.peer.set_aside = aside;
2306        seen.acked_now = (!aside).then(|| entry.acked.clone());
2307        // News of it only from a copy newer by its own times than the newest
2308        // read here: an older genuine copy put back (a server replaying them)
2309        // is no news, and never keeps a phone's word "fresh" (the ninth
2310        // review's W1); nor a copy dated far ahead of this clock (F1).
2311        let newer = !from_the_future(entry, now) && seen.entry.as_ref().is_none_or(|last| sioul_sync::devices::written(entry) > sioul_sync::devices::written(last));
2312        if newer {
2313            // Seen changing now (this device's clock); its export dated later
2314            // than now, its clock is ahead of this one's by that much at least.
2315            seen.peer.seen = now;
2316            if seen.entry.as_ref().is_none_or(|last| last.exported != entry.exported) {
2317                seen.aheads.push(entry.exported - now);
2318                let keep = seen.aheads.len().saturating_sub(60);
2319                seen.aheads.drain(..keep);
2320            }
2321            // Started again since last seen: how late the news of its opening
2322            // came, its clock's lead taken off (a clock behind only adds to it).
2323            if watching && seen.entry.as_ref().is_some_and(|last| entry.started > last.started) {
2324                let ahead = seen.aheads.iter().copied().max().unwrap_or(0).max(0);
2325                note_wake(seen, now - entry.started + ahead, news);
2326            }
2327            seen.entry = Some(entry.clone());
2328            // Said off, or forgotten, until it says anything newer: a new
2329            // start or a later export, in a copy taken as news (a claim renewed
2330            // since: below). A copy set aside or dropped lifts nothing (F1L).
2331            let spoke = |mark: &Mark| entry.started != mark.started || entry.exported > mark.exported;
2332            if seen.off.as_ref().is_some_and(spoke) {
2333                seen.off = None;
2334            }
2335            if seen.forgotten.as_ref().is_some_and(spoke) {
2336                seen.forgotten = None;
2337            }
2338        }
2339        seen.peer.ahead = seen.aheads.iter().copied().max().unwrap_or(0).max(0);
2340        // What it says: its newest copy read here, never an older one put back.
2341        let Some(entry) = seen.entry.clone() else { continue };
2342        let entry = &entry;
2343        // Its entry says it closed, but its claim, renewed open more than a
2344        // minute after that close (both on its own clock), says it runs: an
2345        // older Sioul there now (a version put back), which updates no entry.
2346        // The entry is not trusted: known by its claims, as an older Sioul is.
2347        // Not so of one set aside: its claims, dated ahead as its entry is,
2348        // come after any close it said (its doubt says its clock runs ahead).
2349        let alive_since = claims.iter().any(|c| c.computer == entry.id && !c.closed && c.renewed > entry.closed + 60);
2350        if !entry.working && alive_since && !aside {
2351            continue;
2352        }
2353        seen.peer.said = Some(entry.said());
2354        // The latest opening ever read of it: an older copy put back never lowers it (N3).
2355        seen.peer.latest_opening = seen.peer.latest_opening.max(entry.started);
2356        // Everything it wrote up to its last export, read here.
2357        seen.peer.complete = entry.wrote.is_none_or(|wrote| heard.complete(&entry.id, wrote));
2358        seen.peer.heard = seen.peer.heard.max(entry.exported);
2359        seen.peer.broken = heard.broken.get(&entry.id).copied();
2360    }
2361    // Said off, or forgotten, until it says anything newer: a new start or a
2362    // new export (above, in a copy taken as news), a claim renewed since.
2363    for seen in kept.peers.values_mut() {
2364        seen.peer.wake = seen.wakes.iter().copied().max();
2365        let renewed = seen.renewed;
2366        let newer = |mark: &Mark| renewed > mark.renewed;
2367        if seen.off.as_ref().is_some_and(newer) {
2368            seen.off = None;
2369        }
2370        if seen.forgotten.as_ref().is_some_and(newer) {
2371            seen.forgotten = None;
2372        }
2373        seen.peer.off = seen.off.is_some() || seen.forgotten.is_some();
2374        seen.peer.forgotten = seen.forgotten.is_some();
2375    }
2376}
2377
2378/// A device you say is off (`off`), or not any more: not counted for the
2379/// doses until it says anything newer (a new start, a new export, a claim),
2380/// said plainly meanwhile (`shared_note`, Settings). It never answers a dose
2381/// for you. Returns what went wrong, else "".
2382pub(crate) fn device_off(id: &str, off: bool) -> String {
2383    mark_device(id, |seen, mark| seen.off = off.then_some(mark))
2384}
2385
2386/// Since when another device has said nothing, as this device sees it: the
2387/// later of when this device saw its entry change and its own last word
2388/// (`Peer::seen`, `Peer::heard`), as "Forget it now" judges it
2389/// (`FORGET_NOW_AFTER`); none while this device knows nothing of it.
2390pub(crate) fn quiet_since(id: &str) -> Option<i64> {
2391    load_peers().peers.get(id).map(|seen| seen.peer.seen.max(seen.peer.heard)).filter(|since| *since > 0)
2392}
2393
2394/// Whether `id` is forgotten here (Forget this device), or replaced by
2395/// another device (`apply_replaced`), as this device last knew it.
2396pub(crate) fn forgotten_here(id: &str) -> bool {
2397    load_peers().peers.get(id).is_some_and(|seen| seen.forgotten.is_some() || seen.peer.forgotten)
2398}
2399
2400/// A device silent for a week, forgotten: not listed until it says anything newer.
2401pub(crate) fn device_forget(id: &str) -> String {
2402    // Forgotten by the sharing too: it no longer holds its newer form back (docs/database.md).
2403    crate::share::forget_device(id);
2404    mark_device(id, |seen, mark| seen.forgotten = Some(mark))
2405}
2406
2407/// A device whose files you removed from the folder while it no longer
2408/// counted for the sharing (it left, was forgotten or replaced): forgotten
2409/// by the doses too, exactly as "Forget it now" does, before its claims go
2410/// (its entry too, once nothing needs it): a device whose entry no longer
2411/// reads is known by its claims, as an older Sioul, and with none left the
2412/// doses would doubt for it until it had been silent `SILENT_DAYS`. It
2413/// counts again if it says anything newer. Returns what went wrong, else "".
2414pub(crate) fn device_forget_doses(id: &str) -> String {
2415    mark_device(id, |seen, mark| seen.forgotten = Some(mark))
2416}
2417
2418fn mark_device(id: &str, set: impl FnOnce(&mut PeerSeen, Mark)) -> String {
2419    let id = id.trim();
2420    if id.is_empty() {
2421        return String::new();
2422    }
2423    // What it says now, learned first: the mark is lifted by anything newer than that only.
2424    let _ = know();
2425    let _held = KNOWING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2426    sioul_core::filelock::with_lock(&peers_path(), || {
2427        let mut kept = load_peers();
2428        let seen = kept.peers.entry(id.to_string()).or_default();
2429        seen.peer.id = id.to_string();
2430        let mark = Mark { started: seen.entry.as_ref().map_or(0, |e| e.started), exported: seen.entry.as_ref().map_or(0, |e| e.exported), renewed: seen.renewed, at: Timestamp::now().as_second() };
2431        set(seen, mark);
2432        save_peers(&kept)
2433    })
2434}
2435
2436/// How long a phone says nothing before a computer offers to forget it now
2437/// (`DeviceRow::forget_now`): a day, where a living phone restates its entry
2438/// every hour (`devices::RESTATED`).
2439pub(crate) const FORGET_NOW_AFTER: i64 = 86_400;
2440
2441/// A device as Settings ▸ Your folder and sharing lists it (`share::status`).
2442#[derive(Serialize, Debug)]
2443pub(crate) struct DeviceRow {
2444    id: String,
2445    name: String,
2446    /// How it is, in words: in use now, closed at 22:14, silent since…, off as you said, an older Sioul.
2447    state: String,
2448    /// Said under it, quietly: it does not share its doses; its clock ahead.
2449    notes: Vec<String>,
2450    /// Counted as off, as you said: "Count it again".
2451    off: bool,
2452    /// Silent for a week: "Forget this device".
2453    forget: bool,
2454    /// On a computer, a phone silent for a day or more, still counted:
2455    /// "Forget it now", said with what it changes (a phone that will never
2456    /// share again, dead or lost, holds the doses' reminders back for a week otherwise).
2457    forget_now: bool,
2458    /// The Sioul it runs, as its entry says it: "Sioul 0.0.3 (eff8661abcde)", said
2459    /// calmly when older than this device's; "" when its entry does not say.
2460    build: String,
2461}
2462
2463/// Your other devices, as this one knows them, for Settings: nothing red, no
2464/// counts. `holders`: the devices that hold the newer form of what travels
2465/// back, or run an older Sioul once it travels (`share::formats`), with when
2466/// each was last heard (seconds, 0 when nothing says it) and whether it holds
2467/// it back; each is listed (`holder_rows`).
2468pub(crate) fn device_rows(holders: &[(String, i64, bool)]) -> Vec<DeviceRow> {
2469    let knowledge = know();
2470    let kept = load_peers();
2471    let mut rows = device_rows_of(&knowledge.peers, &kept, Timestamp::now().as_second(), tr());
2472    holder_rows(&mut rows, holders, tr());
2473    rows
2474}
2475
2476/// Every device holding the newer form back has its line, so that the window
2477/// says which (docs/database.md, "The format of what travels"): one the rows
2478/// do not list (known to the sharing alone, by what it shared before, or
2479/// forgotten here while the sharing heard it since) is added, with "Forget
2480/// this device": it is not counted for your doses, so forgetting it makes no
2481/// dose look known. One listed keeps its own line: forgotten only once silent
2482/// for a week, as any device, since a device counted for your doses and
2483/// forgotten could make a dose it took look not taken.
2484fn holder_rows(rows: &mut Vec<DeviceRow>, holders: &[(String, i64, bool)], words: &Translator) {
2485    let zone = jiff::tz::TimeZone::system();
2486    let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2487    for (id, heard, holds) in holders {
2488        if id.is_empty() || rows.iter().any(|row| row.id == *id) {
2489            continue;
2490        }
2491        let state = match (holds, *heard > 0) {
2492            (true, true) => said(words, "share-device-holds", &[("when", when(*heard))]),
2493            (true, false) => words.text("share-device-holds-unknown", None),
2494            (false, true) => said(words, "share-device-older", &[("when", when(*heard))]),
2495            (false, false) => words.text("share-device-unread", None),
2496        };
2497        rows.push(DeviceRow { id: id.clone(), name: capitalized(&words.text("share-other-device", None)), state, notes: Vec::new(), off: false, forget: true, forget_now: false, build: String::new() });
2498    }
2499    rows.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
2500}
2501
2502fn device_rows_of(peers: &[Peer], kept: &Peers, now: i64, words: &Translator) -> Vec<DeviceRow> {
2503    let zone = jiff::tz::TimeZone::system();
2504    let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2505    let mut rows: Vec<DeviceRow> = peers
2506        .iter()
2507        // Forgotten here, or replaced by another device (`apply_replaced`): not listed until it says anything newer.
2508        .filter(|peer| !peer.id.is_empty() && !peer.forgotten && kept.peers.get(&peer.id).is_none_or(|seen| seen.forgotten.is_none()))
2509        .map(|peer| {
2510            let seen = kept.peers.get(&peer.id);
2511            let entry = seen.and_then(|s| s.entry.as_ref()).filter(|_| peer.said.is_some());
2512            let named = named_of(peer);
2513            let name = match entry {
2514                Some(e) if e.kind == sioul_sync::devices::PHONE && !e.name.is_empty() && e.name != sioul_sync::devices::PHONE => format!("{} ({})", capitalized(&words.text("device-the-phone", None)), e.name),
2515                _ => device_start(&named, words),
2516            };
2517            let off = peer.off;
2518            let counted = sioul_core::health::counts(&Peer { off: false, ..peer.clone() }, now);
2519            // Its entry from a clock far ahead of this one's, set aside (`FUTURE`): said, so that its clock is set right.
2520            let future = peer.set_aside;
2521            let state = if off {
2522                words.text("share-device-off", None)
2523            } else if future && entry.is_none() {
2524                words.text("share-device-future", None)
2525            } else if !counted {
2526                said(words, "share-device-silent", &[("when", when(peer.heard))])
2527            } else {
2528                match entry {
2529                    Some(e) if e.working && now - peer.seen > sioul_core::health::QUIET => said(words, "share-device-quiet", &[("when", when(e.exported))]),
2530                    Some(e) if e.working => said(words, "share-device-in-use", &[("when", when(e.exported))]),
2531                    Some(e) => said(words, "share-device-closed", &[("when", when(e.closed)), ("shared", when(e.exported))]),
2532                    None if seen.is_some_and(|s| s.renewed > 0) => said(words, "share-device-older", &[("when", when(peer.heard))]),
2533                    None => words.text("share-device-unread", None),
2534                }
2535            };
2536            let mut notes = Vec::new();
2537            if entry.is_some_and(|e| !e.doses) {
2538                notes.push(words.text("share-device-apart", None));
2539            }
2540            if future && (off || entry.is_some()) {
2541                notes.push(words.text("share-device-future", None));
2542            } else if peer.ahead >= 60 {
2543                let mut args = sioul_core::i18n::args();
2544                args.set("minutes", peer.ahead / 60);
2545                notes.push(words.text("share-device-ahead", Some(&args)));
2546            }
2547            // The build it runs (docs/database.md, "Devices"): an older one said, calmly.
2548            let build = match entry.map(|e| (e.build(), sioul_core::build::older(&e.version, sioul_core::build::VERSION))) {
2549                Some((build, _)) if build.is_empty() => String::new(),
2550                Some((build, true)) => said(words, "share-device-build-older", &[("build", build), ("here", sioul_core::build::DESCRIBED.to_string())]),
2551                Some((build, false)) => said(words, "share-device-build", &[("build", build)]),
2552                None => String::new(),
2553            };
2554            // Forgotten once silent a week, or once said off (a phone that never gives its word holds the computers until then: L2).
2555            let forget = !counted || off;
2556            // On a computer, a phone silent a day (its entry not changed here since: a living
2557            // phone restates it each hour): "Forget it now", rather than a week's wait.
2558            let phone = peer.said.as_ref().is_some_and(|s| s.phone) || entry.is_some_and(|e| e.kind == sioul_sync::devices::PHONE);
2559            let forget_now = !forget && phone && crate::devices::kind() == sioul_sync::devices::COMPUTER && now - peer.seen.max(peer.heard) >= FORGET_NOW_AFTER;
2560            DeviceRow { id: peer.id.clone(), name, state, notes, off, forget, forget_now, build }
2561        })
2562        .collect();
2563    rows.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
2564    rows
2565}
2566
2567/// Why a dose is not known, in words: "your laptop: last heard at 07:52".
2568fn why(doubts: &[Doubt]) -> String {
2569    why_in(doubts, tr())
2570}
2571
2572/// The same, in `words`' language (the tests give theirs).
2573fn why_in(doubts: &[Doubt], words: &Translator) -> String {
2574    joined(why_parts(doubts, words), words)
2575}
2576
2577/// Each reason in words, one per doubt.
2578fn why_parts(doubts: &[Doubt], words: &Translator) -> Vec<String> {
2579    let zone = jiff::tz::TimeZone::system();
2580    let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2581    let named = |device: &sioul_core::health::Named| device_word(device, words);
2582    let parts: Vec<String> = doubts
2583        .iter()
2584        .map(|doubt| match doubt {
2585            Doubt::Record { since } => said(words, "dose-doubt-record", &[("when", when(*since))]),
2586            Doubt::Unheard { device, until: 0, .. } => said(words, "dose-doubt-never", &[("name", named(device))]),
2587            Doubt::Unheard { device, until, closed: true } => said(words, "dose-doubt-closed", &[("name", named(device)), ("when", when(*until))]),
2588            Doubt::Unheard { device, until, closed: false } => said(words, "dose-doubt-open", &[("name", named(device)), ("when", when(*until))]),
2589            Doubt::Broken { device } => said(words, "dose-doubt-broken", &[("name", named(device))]),
2590            Doubt::Working { device, shared, quiet: false } => said(words, "dose-doubt-working", &[("name", named(device)), ("when", when(*shared))]),
2591            Doubt::Working { device, shared, quiet: true } => said(words, "dose-doubt-quiet", &[("name", named(device)), ("when", when(*shared))]),
2592            Doubt::Coming { device, shared } => said(words, "dose-doubt-coming", &[("name", named(device)), ("when", when(*shared))]),
2593            Doubt::Apart { device } => said(words, "dose-doubt-apart", &[("name", named(device))]),
2594            Doubt::Ahead { device } => said(words, "dose-doubt-ahead", &[("name", named(device))]),
2595        })
2596        .collect();
2597    parts
2598}
2599
2600/// Reasons said one after the other, as `words`' language joins them
2601/// ("…; …", in French "… ; …" with its thin space: the fifth review's L6).
2602fn joined(parts: Vec<String>, words: &Translator) -> String {
2603    // A reason said once, however many ways it came (the eighth review's L1).
2604    let mut once: Vec<String> = Vec::new();
2605    for part in parts {
2606        if !once.contains(&part) {
2607            once.push(part);
2608        }
2609    }
2610    once.into_iter().reduce(|first, then| said(words, "dose-doubt-and", &[("first", first), ("then", then)])).unwrap_or_default()
2611}
2612
2613/// Why a phone's alarm cannot know it is a dose's first reminder, in words,
2614/// each device said (`dose_reminders::others`): "the desk may have shown its
2615/// reminder at 08:00", "Pixel may have reminded you too, by its own alarm".
2616fn elsewhere_said(elsewhere: &[Elsewhere], since: i64, words: &Translator) -> String {
2617    let zone = jiff::tz::TimeZone::system();
2618    let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2619    let parts = elsewhere
2620        .iter()
2621        .map(|e| match e {
2622            Elsewhere::Sent { device, at } => said(words, "dose-doubt-maybe", &[("name", device_word(device, words)), ("when", when(*at))]),
2623            Elsewhere::Unshown { device, at } => said(words, "dose-doubt-maybe-late", &[("name", device_word(device, words)), ("when", when(*at))]),
2624            Elsewhere::Unknown { device } => said(words, "dose-doubt-unknown", &[("name", device_word(device, words)), ("when", when(since))]),
2625            Elsewhere::Closed { device, at } => said(words, "dose-doubt-closed", &[("name", device_word(device, words)), ("when", when(*at))]),
2626            Elsewhere::Alarm { device } => said(words, "dose-doubt-alarm", &[("name", device_word(device, words))]),
2627            // The same words as its doubt (`why_parts`): said once (`joined`, `doubt_unread_with`).
2628            Elsewhere::Ahead { device } => said(words, "dose-doubt-ahead", &[("name", device_word(device, words))]),
2629            Elsewhere::Unread => words.text("dose-doubt-reminders", None),
2630        })
2631        .collect();
2632    joined(parts, words)
2633}
2634
2635/// A moment as a doubt says it: "at 07:45" today, "on Monday 5 October at 07:45" before.
2636fn when_said(at: &Zoned, words: &Translator) -> String {
2637    if at.date() == Zoned::now().with_time_zone(at.time_zone().clone()).date() { said(words, "when-at", &[("time", at.strftime("%H:%M").to_string())]) } else { words.when(at) }
2638}
2639
2640/// A device as a doubt names it: "the phone", else its name; one whose name
2641/// is not known (an older Sioul on a phone says "localhost"), "another device of yours".
2642fn device_word(device: &sioul_core::health::Named, words: &Translator) -> String {
2643    if device.phone {
2644        words.text("device-the-phone", None)
2645    } else if device.name.trim().is_empty() || device.name == "localhost" || device.name == "?" {
2646        words.text("share-other-device", None)
2647    } else {
2648        device.name.clone()
2649    }
2650}
2651
2652/// A message with its words given, in `words`' language (`say`, for any translator).
2653fn said(words: &Translator, id: &str, pairs: &[(&str, String)]) -> String {
2654    let mut args = sioul_core::i18n::args();
2655    for (key, value) in pairs {
2656        args.set(key.to_string(), value.clone());
2657    }
2658    words.text(id, Some(&args))
2659}
2660
2661/// A dose's doubt as its reminder says it, at its time (`waking`: the
2662/// waking it waited for, else its time); "" when it is known.
2663fn doubt_of(knowledge: &Knowledge, due: i64, waking: i64, now: i64) -> String {
2664    doubt_unread(knowledge, due, waking, now, None)
2665}
2666
2667/// The doubt, `first` said before what the devices leave unknown (a reminder
2668/// that comes late, one another device may have shown): "Sioul can't tell
2669/// whether it was taken: …. Check before taking it."; "" when nothing is.
2670/// What looks back counts from the dose's time, the opening for it from
2671/// `waking` (`sioul_core::health::doubts_at`; the sixth review's M1).
2672fn doubt_unread_with(knowledge: &Knowledge, due: i64, waking: i64, now: i64, missed: Option<&(i64, std::collections::BTreeSet<String>)>, first: Option<&str>) -> String {
2673    let doubts = match missed {
2674        Some((since, heard)) => sioul_core::health::doubts_unread_at(due, waking, now, knowledge.record_lost, &knowledge.peers, *since, heard),
2675        None => sioul_core::health::doubts_at(due, waking, now, knowledge.record_lost, &knowledge.peers),
2676    };
2677    // The devices' reasons, each said once: one `first` says already is not said again (L1).
2678    let parts: Vec<String> = why_parts(&doubts, tr()).into_iter().filter(|part| first.is_none_or(|first| !first.contains(part.as_str()))).collect();
2679    let reasons = (!parts.is_empty()).then(|| joined(parts, tr()));
2680    let why = match (first, reasons) {
2681        (None, None) => return String::new(),
2682        (Some(first), None) => first.to_string(),
2683        (None, Some(reasons)) => reasons,
2684        (Some(first), Some(reasons)) => joined(vec![first.to_string(), reasons], tr()),
2685    };
2686    say("dose-doubt", &[("why", why)])
2687}
2688
2689/// A moment (Unix seconds) as a doubt says it (`when_said`).
2690fn moment_said(at: i64) -> String {
2691    Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(TimeZone::system()), tr())).unwrap_or_default()
2692}
2693
2694/// Whether a phone may remind the doses by its own alarm: one in the folder,
2695/// counted or not (silent a while, its alarms may still be set), or a device
2696/// whose entry does not say (an older Sioul, one not read): this computer
2697/// then keeps quiet around that alarm, promised (the sixth review's R3).
2698fn phone_alarms(knowledge: &Knowledge) -> bool {
2699    knowledge.peers.iter().any(|p| p.said.as_ref().is_none_or(|said| said.phone && !said.left))
2700}
2701
2702/// The devices that must say they read this computer's opening before it
2703/// may be a dose's first (`first_here`): every phone in the folder that has
2704/// not stopped sharing and that you have not forgotten here, counted or not,
2705/// said off or not, silent or not (the seventh review's C1: what counts is
2706/// each device's own view, maybe stale); and every device whose entry does
2707/// not say what it is (an older Sioul, one not read), which never says it:
2708/// this computer is then never the first.
2709fn must_acknowledge(knowledge: &Knowledge) -> Vec<&Peer> {
2710    knowledge.peers.iter().filter(|p| !p.forgotten && p.said.as_ref().is_none_or(|said| said.phone && !said.left)).collect()
2711}
2712
2713/// What each phone's sealed entry says of `computer`'s opening
2714/// (`devices::Entry::acked`), by phone.
2715fn acknowledgements(computer: &str, entries: &[sioul_sync::devices::Entry]) -> std::collections::BTreeMap<String, i64> {
2716    entries.iter().filter(|e| e.kind == sioul_sync::devices::PHONE).filter_map(|e| e.acked.get(computer).map(|opening| (e.id.clone(), *opening))).collect()
2717}
2718
2719/// A phone's word on each computer it counts now (`Entry::acked`): the
2720/// opening of that computer's entry it read. Written by phones only, of
2721/// computers only: none elsewhere (the seventh review's L4). A computer said
2722/// off, forgotten or silent a week here goes from it (C1).
2723pub(crate) fn acknowledged_openings() -> std::collections::BTreeMap<String, i64> {
2724    if crate::devices::kind() != sioul_sync::devices::PHONE {
2725        return std::collections::BTreeMap::new();
2726    }
2727    let now = Timestamp::now().as_second();
2728    know()
2729        .peers
2730        .iter()
2731        .filter(|p| sioul_core::health::counts(p, now))
2732        // Its latest opening ever read: a word that never goes back (the eighth review's N3).
2733        .filter_map(|p| p.said.as_ref().filter(|s| !s.phone && s.started > 0).map(|s| (p.id.clone(), s.started.max(p.latest_opening))))
2734        .collect()
2735}
2736
2737/// Since when every device that must (`must_acknowledge`) said in its sealed
2738/// entry that it read this computer's opening, `start`, as this process saw
2739/// it first: kept across ticks for the same opening; none while one has not.
2740static ACKED: std::sync::Mutex<Option<(i64, i64)>> = std::sync::Mutex::new(None);
2741
2742/// How long a phone's word counts on this computer after this computer last
2743/// saw that phone's entry change (`Peer::seen`): twice the hour within which a
2744/// living phone restates its entry (`devices::RESTATED`), one restatement
2745/// missed allowed. Past it the phone may have taken its word back (said this
2746/// computer off, or stopped counting it after a week's silence) without this
2747/// computer hearing it: no word, this computer is not the first (the eighth
2748/// review's N1, N2).
2749const WORD_FRESH: i64 = 2 * sioul_sync::devices::RESTATED;
2750
2751/// `ACKED` brought up to date at `now`; since when all acknowledged, if they do.
2752fn acknowledged_since(knowledge: &Knowledge, start: i64, acks: &std::collections::BTreeMap<String, i64>, now: i64) -> Option<i64> {
2753    let all = all_acknowledged(knowledge, start, acks, now);
2754    let mut acked = ACKED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2755    *acked = acked_after(*acked, all, start, now);
2756    acked.map(|(_, at)| at)
2757}
2758
2759/// Whether every device that must (`must_acknowledge`) said it read this
2760/// computer's opening `start`, that very one (`acks`: `acknowledgements`): a
2761/// word naming another opening, later or impossible, says nothing (M1); a
2762/// word of a phone not heard here within `WORD_FRESH` says nothing either
2763/// (N1, N2).
2764fn all_acknowledged(knowledge: &Knowledge, start: i64, acks: &std::collections::BTreeMap<String, i64>, now: i64) -> bool {
2765    start > 0 && must_acknowledge(knowledge).iter().all(|p| acks.get(&p.id) == Some(&start) && (0..=WORD_FRESH).contains(&(now - p.seen)))
2766}
2767
2768/// `ACKED`'s next value: kept for the same opening while all still say it,
2769/// from `now` for a new one, none while one does not.
2770fn acked_after(before: Option<(i64, i64)>, all: bool, start: i64, now: i64) -> Option<(i64, i64)> {
2771    match before {
2772        Some((opening, at)) if all && opening == start => Some((opening, at)),
2773        _ if all => Some((start, now)),
2774        _ => None,
2775    }
2776}
2777
2778/// How long before the dose's time the phones' word must be seen here: two
2779/// minutes (`SKEW`) and the most any of them was measured ahead of this
2780/// computer's clock (`Peer::ahead`): a phone ahead decides its alarm that
2781/// much earlier (the seventh review's L1).
2782fn word_lead(knowledge: &Knowledge) -> i64 {
2783    sioul_core::health::SKEW + must_acknowledge(knowledge).iter().map(|p| p.ahead.max(0)).max().unwrap_or(0)
2784}
2785
2786/// Whether this computer may remind a dose to remind from `since` plainly, as
2787/// its first (docs/health.md, "What became of a reminder"; the sixth and
2788/// seventh reviews' C1): alone (sharing off, or no other device in the
2789/// folder); or its opening, its claim and its records written (`told`), the
2790/// doses kept here since before `since` (`kept_since`), and every phone's
2791/// word that it read this opening seen here `lead` before `since` (`acked`).
2792/// Else it is as a computer opened after the dose: quiet around a phone's
2793/// alarm, then the doubt first.
2794fn first_here(alone: bool, told: bool, kept_since: i64, acked: Option<i64>, since: i64, lead: i64) -> bool {
2795    alone || (told && kept_since <= since && acked.is_some_and(|at| at <= since - lead))
2796}
2797
2798/// The same, at an alarm whose pull did not go through since it began
2799/// (`share::news_missed`: when, and the devices whose entry or health claim
2800/// says more since): any other known closed by what was read before is said in doubt,
2801/// as last heard (`sioul_core::health::doubts_unread`).
2802fn doubt_unread(knowledge: &Knowledge, due: i64, waking: i64, now: i64, missed: Option<&(i64, std::collections::BTreeSet<String>)>) -> String {
2803    let doubts = match missed {
2804        Some((since, heard)) => sioul_core::health::doubts_unread_at(due, waking, now, knowledge.record_lost, &knowledge.peers, *since, heard),
2805        None => sioul_core::health::doubts_at(due, waking, now, knowledge.record_lost, &knowledge.peers),
2806    };
2807    if doubts.is_empty() { String::new() } else { say("dose-doubt", &[("why", why(&doubts))]) }
2808}
2809
2810/// A dose's doubt as what stays in view says it (the Porch, the Health page,
2811/// where a dose shows due hours after its time: as of now,
2812/// `health::doubts_now`), and a "This device is off" for each device it names
2813/// (one: "This device is off"; several: each by its name). None for a line
2814/// that could not be read, nor a device that does not share its doses: its
2815/// being off would lift neither.
2816fn doubt_now_with(knowledge: &Knowledge, due: i64, now: i64, words: &Translator) -> (String, Vec<OffButton>) {
2817    let doubts = sioul_core::health::doubts_now(due, now, knowledge.record_lost, &knowledge.peers);
2818    if doubts.is_empty() {
2819        return (String::new(), Vec::new());
2820    }
2821    let named: Vec<&Named> = doubts
2822        .iter()
2823        .filter_map(|doubt| match doubt {
2824            Doubt::Working { device, .. } | Doubt::Coming { device, .. } | Doubt::Unheard { device, .. } => Some(device),
2825            _ => None,
2826        })
2827        .filter(|device| !device.id.is_empty())
2828        .collect();
2829    let off = match named.as_slice() {
2830        [one] => vec![OffButton { id: one.id.clone(), label: words.text("device-off", None) }],
2831        many => many.iter().map(|device| OffButton { id: device.id.clone(), label: said(words, "device-off-named", &[("name", device_start(device, words))]) }).collect(),
2832    };
2833    (said(words, "dose-doubt", &[("why", why_in(&doubts, words))]), off)
2834}
2835
2836/// A device named at a sentence's start: "The phone", "Another device of yours", else its name as it is.
2837fn device_start(device: &Named, words: &Translator) -> String {
2838    let word = device_word(device, words);
2839    if word == device.name { word } else { capitalized(&word) }
2840}
2841
2842/// A device named by its id, from what is known here of the others: "the
2843/// phone" when it is the one phone, else its name; one not known, "another
2844/// device of yours".
2845fn named_by(knowledge: &Knowledge, id: &str) -> Named {
2846    let phones = knowledge.peers.iter().filter(|p| p.said.as_ref().is_some_and(|s| s.phone)).count();
2847    knowledge.peers.iter().find(|p| p.id == id).map_or_else(|| Named { id: id.to_string(), name: String::new(), phone: false }, |p| Named { id: p.id.clone(), name: p.name.clone(), phone: p.said.as_ref().is_some_and(|s| s.phone) && phones == 1 })
2848}
2849
2850/// A device as a doubt names it, from what is known of it.
2851fn named_of(peer: &Peer) -> Named {
2852    Named { id: peer.id.clone(), name: peer.name.clone(), phone: peer.said.as_ref().is_some_and(|s| s.phone) }
2853}
2854
2855/// Answers that differ, each said, the earliest first: "Marked taken on the
2856/// phone at 08:02, skipped here at 08:10. Check which is right before taking
2857/// it." `me`: this device's id, said "here".
2858fn differ_sentence(given: &[Given], me: &str, words: &Translator) -> String {
2859    let zone = jiff::tz::TimeZone::system();
2860    let parts: Vec<String> = given
2861        .iter()
2862        .map(|g| {
2863            let when = Timestamp::from_second(g.at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2864            let name = device_word(&Named { id: g.device.clone(), name: g.name.clone(), phone: g.kind == sioul_sync::devices::PHONE }, words);
2865            let here = !me.is_empty() && g.device == me;
2866            let id = match (&g.state, g.device.is_empty(), here) {
2867                (State::Taken, true, _) => "dose-answer-taken",
2868                (State::Taken, _, true) => "dose-answer-taken-here",
2869                (State::Taken, _, false) => "dose-answer-taken-on",
2870                (State::Skipped, true, _) => "dose-answer-skipped",
2871                (State::Skipped, _, true) => "dose-answer-skipped-here",
2872                (State::Skipped, _, false) => "dose-answer-skipped-on",
2873                (State::Other(_), _, _) => "dose-answer-other-on",
2874            };
2875            said(words, id, &[("name", name), ("when", when)])
2876        })
2877        .collect();
2878    said(words, "dose-answers", &[("answers", parts.join(", "))])
2879}
2880
2881/// The doses' state is read, changed and written by the minute's tick, the
2882/// page and the notifications' buttons: one at a time, so that none writes
2883/// back an older state over another's mark (a dose shown untaken could be taken twice).
2884static STATE: std::sync::Mutex<()> = std::sync::Mutex::new(());
2885
2886fn state_held() -> std::sync::MutexGuard<'static, ()> {
2887    STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
2888}
2889
2890/// One tick at a time: a slow one (a shared folder) is not overtaken by the next.
2891static TICKING: std::sync::Mutex<()> = std::sync::Mutex::new(());
2892
2893/// A dose not taken, said afterwards: asked no more. Returns what went wrong, else "".
2894pub(crate) fn not_taken(key: &str) -> String {
2895    let now = Timestamp::now().as_second();
2896    let problem = change(|state| {
2897        state.not_taken.insert(key.to_string(), now);
2898    });
2899    if !problem.is_empty() {
2900        return problem;
2901    }
2902    // Your answer, in the doses' records too (`sioul_core::doses`).
2903    record_answer(key, sioul_core::doses::SKIPPED, now, false)
2904}
2905
2906/// You chose, seeing answers that differ (`DoseRow::choose`): taken (at the
2907/// time the earliest "taken" said, else now), or not taken. Your answer here,
2908/// every answer saying otherwise taken out, on purpose; the marks beside, for
2909/// an older Sioul, as you chose; not taken after all, the doses its take moved
2910/// go back. Returns what went wrong, else "".
2911pub(crate) fn choose(key: &str, taken: bool) -> String {
2912    let _held = state_held();
2913    let now = Timestamp::now().as_second();
2914    let first_taken = match sioul_core::doses::answered(key, &record(), &records()) {
2915        Answered::Taken(given) => Some(given.at),
2916        Answered::Differ(given) => given.iter().find(|g| g.state == State::Taken).map(|g| g.at),
2917        _ => None,
2918    };
2919    let at = if taken { first_taken.unwrap_or(now) } else { now };
2920    let moved = std::sync::Mutex::new(None);
2921    let problem = change(|state| {
2922        if taken {
2923            state.taken.insert(key.to_string(), at);
2924            state.not_taken.remove(key);
2925        } else {
2926            state.taken.remove(key);
2927            state.not_taken.insert(key.to_string(), now);
2928            if let Ok(mut moved) = moved.lock() {
2929                *moved = state.moved.remove(key);
2930            }
2931        }
2932    });
2933    if let Some([before, after]) = moved.into_inner().ok().flatten() {
2934        let _ = change_health(|health| Ok(health.taken_back(key, before, after)));
2935    }
2936    if !problem.is_empty() {
2937        return problem;
2938    }
2939    record_answer(key, if taken { sioul_core::doses::TAKEN } else { sioul_core::doses::SKIPPED }, at, true)
2940}
2941
2942/// When a dose is due, from its key (`<medicine>@<Unix seconds>`).
2943fn due_of(key: &str) -> Option<i64> {
2944    key.rsplit_once('@').and_then(|(_, at)| at.parse().ok())
2945}
2946
2947/// Whether a dose marked now is late: more than half an hour past its time.
2948pub(crate) fn is_late(key: &str) -> bool {
2949    due_of(key).is_some_and(|due| Timestamp::now().as_second() - due > GRACE_MINUTES * 60)
2950}
2951
2952/// A dose taken late, at `time` ("09:30", the last such time before now),
2953/// as you say: marked then; for a medicine taken every few hours, the next
2954/// doses that many hours after it. Returns what went wrong, else "".
2955pub(crate) fn taken_late(key: &str, time: &str) -> String {
2956    let now = Zoned::now();
2957    let Some(clock) = time.trim().split_once(':').and_then(|(h, m)| jiff::civil::Time::new(h.trim().parse().ok()?, m.trim().parse().ok()?, 0, 0).ok()) else {
2958        return tr().text("dose-time-wrong", None);
2959    };
2960    let mut at = now.date().to_datetime(clock).to_zoned(now.time_zone().clone()).map(|z| z.timestamp().as_second()).unwrap_or(now.timestamp().as_second());
2961    if at > now.timestamp().as_second() {
2962        at -= 86_400;
2963    }
2964    let _held = state_held();
2965    // The doses it moves, moved in the medicines as the file holds them now, under its lock.
2966    let moved = match change_health(|health| Ok(health.taken_at(key, at))) {
2967        Ok(moved) => moved,
2968        Err(e) => return e,
2969    };
2970    let problem = change(|state| {
2971        state.taken.insert(key.to_string(), at);
2972        state.not_taken.remove(key);
2973        if let Some((before, after)) = moved {
2974            state.moved.insert(key.to_string(), [before, after]);
2975        }
2976    });
2977    // Your answer, in the doses' records too, at the time you said.
2978    if problem.is_empty() {
2979        let _ = record_answer(key, sioul_core::doses::TAKEN, at, false);
2980    }
2981    // Not marked: the doses it moved go back, in the medicines as the file holds them then.
2982    if !problem.is_empty()
2983        && let Some((before, after)) = moved
2984    {
2985        let _ = change_health(|health| Ok(health.taken_back(key, before, after)));
2986    }
2987    problem
2988}
2989
2990/// What the late dose's question shows, as JSON: {name, dose, due, now, hours}.
2991pub(crate) fn dose_info(key: &str) -> String {
2992    let health = load();
2993    let now = Zoned::now();
2994    let (Some((id, _)), Some(due)) = (key.rsplit_once('@'), due_of(key)) else { return "null".into() };
2995    let Some(medicine) = health.medicines.iter().find(|m| m.id == id) else { return "null".into() };
2996    let due = Timestamp::from_second(due).map(|t| t.to_zoned(now.time_zone().clone())).unwrap_or_else(|_| now.clone());
2997    let hours = match medicine.schedule {
2998        Schedule::Hours { hours, .. } => hours,
2999        _ => 0,
3000    };
3001    serde_json::json!({
3002        "name": medicine.short_name(),
3003        "dose": health.amount_of(key, now.time_zone()).unwrap_or_else(|| medicine.dose.clone()),
3004        "due": if due.date() == now.date() { due.strftime("%H:%M").to_string() } else { format!("{} {}", tr().weekday_short(due.date()), due.strftime("%H:%M")) },
3005        "now": now.strftime("%H:%M").to_string(),
3006        // Every few hours: the next dose comes that many hours after the one taken.
3007        "hours": hours,
3008    })
3009    .to_string()
3010}
3011
3012/// A dose marked taken, or not, as it happens: taken every few hours, the
3013/// next doses come that many hours after now; a mark taken back puts them
3014/// back. Returns what went wrong, else "".
3015pub(crate) fn set_taken(key: &str, taken: bool) -> String {
3016    let _held = state_held();
3017    let now = Timestamp::now().as_second();
3018    let shift = if taken {
3019        match change_health(|health| Ok(health.taken_at(key, now))) {
3020            Ok(shift) => shift,
3021            Err(e) => return e,
3022        }
3023    } else {
3024        None
3025    };
3026    let moved = std::sync::Mutex::new(None);
3027    let problem = change(|state| {
3028        if taken {
3029            state.taken.insert(key.to_string(), now);
3030            if let Some((before, after)) = shift {
3031                state.moved.insert(key.to_string(), [before, after]);
3032            }
3033        } else {
3034            state.taken.remove(key);
3035            if let Ok(mut moved) = moved.lock() {
3036                *moved = state.moved.remove(key);
3037            }
3038        }
3039    });
3040    if let Some([before, after]) = moved.into_inner().ok().flatten() {
3041        let _ = change_health(|health| Ok(health.taken_back(key, before, after)));
3042    }
3043    // Your answer, in the doses' records too: taken now; taken back, every
3044    // "taken" goes, on purpose (one click takes it back, wherever it was marked).
3045    if problem.is_empty() {
3046        let _ = if taken { record_answer(key, sioul_core::doses::TAKEN, now, false) } else { change_records(|records| records.take_back(key)) };
3047    }
3048    problem
3049}
3050
3051/// The pause to move and the chats' limit: "movement.enabled", "movement.minutes",
3052/// "chats.enabled", "chats.minutes", "chats.locked_minutes".
3053pub(crate) fn set_setting(key: &str, value: &str) -> String {
3054    let number = || value.trim().parse::<u32>().unwrap_or(0);
3055    if !matches!(key, "movement.enabled" | "movement.minutes" | "chats.enabled" | "chats.minutes" | "chats.locked_minutes" | "errands_list") {
3056        return say("setting-unknown-key", &[("key", key.to_string())]);
3057    }
3058    change_health(|health| {
3059        match key {
3060            "movement.enabled" => health.movement.enabled = value == "true",
3061            "movement.minutes" => health.movement.minutes = number().clamp(10, 240),
3062            "chats.enabled" => health.chats.enabled = value == "true",
3063            "chats.minutes" => health.chats.minutes = number(),
3064            "chats.locked_minutes" => health.chats.locked_minutes = number(),
3065            _ => health.errands_list = value.trim().to_string(),
3066        }
3067        Ok(())
3068    })
3069    .err()
3070    .unwrap_or_default()
3071}
3072
3073/// Minutes of focus before the pause to move; 0 when off.
3074pub(crate) fn movement_minutes() -> i32 {
3075    let movement = load().movement;
3076    if movement.enabled { i32::try_from(movement.minutes).unwrap_or(45) } else { 0 }
3077}
3078
3079/// One more minute in a chat; returns whether chats are covered now.
3080pub(crate) fn chat_minute() -> bool {
3081    let limit = load().chats;
3082    let _held = state_held();
3083    let now = Zoned::now();
3084    let covered = std::sync::atomic::AtomicBool::new(false);
3085    let _ = change(|state| covered.store(state.chat_minute(&limit, &now), std::sync::atomic::Ordering::Relaxed));
3086    covered.into_inner()
3087}
3088
3089/// Whether chats are covered now.
3090pub(crate) fn chats_covered() -> bool {
3091    record().chats_covered(&Zoned::now())
3092}
3093
3094// ---------------------------------------------------------------- meals, rest and sleep
3095
3096/// A block's usual name, in your language: breakfast, lunch, dinner, then
3097/// "Meal 4"; a nap; winding down for the night.
3098pub(crate) fn usual_name(kind: &str, index: usize) -> String {
3099    match (kind, index) {
3100        ("meal", 0..=2) => tr().text(&format!("need-meal-{index}"), None),
3101        ("meal", n) => say("need-meal-n", &[("n", (n + 1).to_string())]),
3102        ("nap", _) => tr().text("need-nap", None),
3103        _ => tr().text("need-sleep", None),
3104    }
3105}
3106
3107/// A block's name: its own, else its usual one by its key ("meal:1": lunch);
3108/// one added for a day without a name of its own: "Meal", "Rest".
3109pub(crate) fn block_name(kind: &str, key: &str, name: &str) -> String {
3110    if !name.trim().is_empty() {
3111        return name.to_string();
3112    }
3113    if sioul_core::needs::is_added(key) {
3114        return tr().text(if kind == "nap" { "need-added-nap" } else { "need-added-meal" }, None);
3115    }
3116    usual_name(kind, key.rsplit_once(':').and_then(|(_, i)| i.parse().ok()).unwrap_or(0))
3117}
3118
3119fn name_of(kept: &Kept) -> String {
3120    block_name(kept.kind, &kept.key, &kept.name)
3121}
3122
3123/// Each day's own meals, naps and nights (`sioul_core::needs::Days`). The
3124/// moves and skips of today an older Sioul kept on this device
3125/// (`needs-today.toml`) are moved into them once, as times.
3126pub(crate) fn days() -> Days {
3127    let path = Days::default_path();
3128    let now = Zoned::now();
3129    let today_path = sioul_core::needs::Today::default_path();
3130    let mut today = sioul_core::needs::Today::load(&today_path, now.date());
3131    if today.shifts.is_empty() && today.skipped.is_empty() {
3132        return Days::load(&path);
3133    }
3134    let zone = now.time_zone().clone();
3135    let blocks = load().needs.blocks_of(now.date(), &zone, &Days::default(), &|_| 0);
3136    // Moved in once, over the days as the file holds them then, under its lock.
3137    let moved = Days::change_file(&path, now.date(), |days| {
3138        for k in &blocks {
3139            let shift = today.shifts.get(&k.key).copied().unwrap_or(0) * 60;
3140            let skipped = today.skipped.contains(&k.key);
3141            days.change(now.date(), &k.key, |b| {
3142                if shift != 0 && b.at.is_empty() {
3143                    b.at = clock(k.at + shift, &zone);
3144                    if k.kind == "sleep" {
3145                        b.wake = clock(k.end + shift, &zone);
3146                    }
3147                }
3148                b.quiet |= skipped;
3149            });
3150        }
3151    });
3152    if moved.is_ok() {
3153        today.shifts.clear();
3154        today.skipped.clear();
3155        let _ = today.save(&today_path);
3156    }
3157    Days::load(&path)
3158}
3159
3160/// Today's events' held times (their margins counted), read again five
3161/// minutes after at most: the needs' tick runs every minute.
3162fn held_today(now: &Zoned) -> Vec<(i64, i64)> {
3163    static CACHE: std::sync::Mutex<Option<(i64, Date, Vec<(i64, i64)>)>> = std::sync::Mutex::new(None);
3164    let stamp = now.timestamp().as_second();
3165    let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
3166    if let Some((at, day, held)) = cache.as_ref()
3167        && *day == now.date()
3168        && (0..300).contains(&(stamp - at))
3169    {
3170        return held.clone();
3171    }
3172    let midnight = now.date().to_zoned(now.time_zone().clone()).map_or(stamp, |z| z.timestamp().as_second());
3173    let held = sioul_core::plan::event_spans(&sioul_core::agenda::occurrences(midnight, midnight + 26 * 3600), 0);
3174    *cache = Some((stamp, now.date(), held.clone()));
3175    held
3176}
3177
3178/// `date`'s events' held times: today's from the minute's cache, another day's read now.
3179fn held_on(date: Date, now: &Zoned) -> Vec<(i64, i64)> {
3180    if date == now.date() {
3181        return held_today(now);
3182    }
3183    let start = midnight(date, now.time_zone());
3184    sioul_core::plan::event_spans(&sioul_core::agenda::occurrences(start, start + 26 * 3600), 0)
3185}
3186
3187/// A day's own blocks as they are: its changes, its meals pushed past its events.
3188fn blocks_now(needs: &Needs, days: &Days, date: Date, now: &Zoned) -> Vec<Kept> {
3189    let pushed = needs.past_events_on(date, now.time_zone(), days, &held_on(date, now));
3190    needs.blocks_of(date, now.time_zone(), days, &|key: &str| pushed.get(key).copied().unwrap_or(0))
3191}
3192
3193/// The needs' notices that may come at `now`, each its block and whether it
3194/// is the heads-up, marked given on this device (`Today::due`): two at most a
3195/// block, each once, only near its time; none while an event goes on, none
3196/// for a block quiet or taken out that day; as the matrix of what reaches you
3197/// lets them come (as usual, nothing while you sleep, in a pause or in Free
3198/// time). The window's minute on a computer and a phone's alarm at the
3199/// night's start both ask here (`needs_tick`, `decide_night`).
3200fn needs_now(health: &Health, now: &Zoned) -> Vec<(Kept, bool)> {
3201    let needs = &health.needs;
3202    let days = days();
3203    if !(needs.meals_on || needs.naps_on || needs.sleep_on) && !days.0.contains_key(&now.date()) {
3204        return Vec::new();
3205    }
3206    let path = sioul_core::needs::Today::default_path();
3207    let mut today = sioul_core::needs::Today::load(&path, now.date());
3208    let stamp = now.timestamp().as_second();
3209    let pushed = needs.past_events_on(now.date(), now.time_zone(), &days, &held_today(now));
3210    let kept = needs.kept_with(now.date(), now.time_zone(), &days, &|key: &str| pushed.get(key).copied().unwrap_or(0));
3211    let due: Vec<(Kept, bool)> = today.due(&kept, needs.heads_up, stamp).into_iter().map(|(block, heads_up)| (block.clone(), heads_up)).collect();
3212    if due.is_empty() {
3213        return Vec::new();
3214    }
3215    let _ = today.save(&path);
3216    // In a meeting, nothing is said: the time stays kept.
3217    let meeting = sioul_core::agenda::occurrences(stamp - 86_400, stamp + 60).iter().any(|e| !e.cancelled && !e.all_day && e.start <= stamp && e.end > stamp);
3218    if meeting {
3219        return Vec::new();
3220    }
3221    let attention = crate::hours::attention();
3222    let moment = crate::hours::attention_now();
3223    let row = sioul_core::attention::Row::Own(sioul_core::attention::Kind::Needs);
3224    let may = attention.level(row, &moment) == sioul_core::attention::Level::Now;
3225    // The night's or a nap's own notice as it begins says that sleep begins:
3226    // sleep's column does not hold it (docs/health.md); a pause does.
3227    let mut awake = moment.clone();
3228    awake.times.retain(|c| *c != sioul_core::attention::Column::Sleep);
3229    let starting = attention.level(row, &awake) == sioul_core::attention::Level::Now;
3230    due.into_iter()
3231        .filter(|(block, heads_up)| {
3232            let own_start = !heads_up && (block.kind == "sleep" || block.kind == "nap") && block.start <= stamp;
3233            may || (own_start && starting)
3234        })
3235        .collect()
3236}
3237
3238/// The night's own notice's buttons (docs/reviews.md): "Close the whole
3239/// day" while that night's review is to give at `now`, its press carrying
3240/// the night's day ("night:2026-10-14"), then "Options…", the block's
3241/// question.
3242fn night_offered(block: &Kept, now: &Zoned) -> Vec<crate::reviews::Offered> {
3243    let options = crate::reviews::Offered { label: tr().text("need-open", None), kind: "need", key: block.key.clone() };
3244    match sioul_core::reviews::night_day(block.at, now.time_zone()).filter(|_| crate::reviews::night_open(block.at, now)) {
3245        Some(day) => vec![crate::reviews::Offered { label: tr().text("review-close-night", None), kind: "review", key: format!("night:{day}") }, options],
3246        None => vec![options],
3247    }
3248}
3249
3250/// Each minute, from the computer you are at (`needs_now`): a block's
3251/// heads-up about the work, `heads_up` minutes before it ("No new big
3252/// task"), with "Later"; then one at its time. Its name and time only: safe
3253/// to be read by someone else (docs/health.md). A phone shows only the
3254/// night's own, at its alarm (`decide_night`).
3255fn needs_tick(qt: &QtThread, shared: &Arc<Shared>, health: &Health, now: &Zoned) {
3256    if cfg!(target_os = "android") {
3257        return;
3258    }
3259    let hm = |at: i64| clock(at, now.time_zone());
3260    for (block, heads_up) in needs_now(health, now) {
3261        let name = name_of(&block);
3262        // One button: the window's question (later, at another time, not
3263        // today; a line on where you stopped).
3264        let (key, qt_open) = (block.key.clone(), qt.clone());
3265        let open: Box<dyn FnOnce() + Send> = Box::new(move || {
3266            let _ = qt_open.queue(move |mut sioul| sioul.as_mut().reminder_opened(QString::from("need"), QString::default(), QString::from(&key)));
3267        });
3268        let action = Some((tr().text("need-open", None), open));
3269        let shown = if heads_up {
3270            sioul_sync::notify::remind(&tr().text("need-heads-up", None), &say("need-at", &[("name", name), ("time", hm(block.start))]), action)
3271        } else if block.kind == "sleep" {
3272            // The night's own notice: closing the day is offered beside its
3273            // options while that night's review is to give, as each system
3274            // shows buttons (docs/reviews.md).
3275            crate::reviews::show_notice(qt, &name, &hm(block.start), night_offered(&block, now))
3276        } else {
3277            sioul_sync::notify::remind(&name, &hm(block.start), action)
3278        };
3279        if let Err(e) = shown {
3280            tell(qt, shared, e);
3281        }
3282    }
3283}
3284
3285/// When the night's own notice may come in the next nights, for a phone's
3286/// alarms (`eventalarms::coming`): as each night's wind-down begins, while
3287/// sleep is kept with its notices, and in the quarter of an hour after, as
3288/// `Today::due` gives it, unless given here already; each
3289/// (`day:night:<date>`, Unix seconds). A start just past stays as it was, so
3290/// that the list stays the same from one minute to the next: Android rings
3291/// it at once.
3292pub(crate) fn coming_night(now: &Zoned) -> Vec<(String, i64)> {
3293    let stamp = now.timestamp().as_second();
3294    let today = sioul_core::needs::Today::load(&sioul_core::needs::Today::default_path(), now.date());
3295    crate::hours::blocks(now)
3296        .kept
3297        .iter()
3298        .filter(|block| block.kind == "sleep" && block.notices && stamp - block.start < 15 * 60)
3299        .filter_map(|block| {
3300            let date = Timestamp::from_second(block.start).ok()?.to_zoned(now.time_zone().clone()).date();
3301            let given = date == now.date() && today.sent.contains(&format!("{}:start", block.key));
3302            (!given).then(|| (format!("day:night:{date}"), block.start))
3303        })
3304        .collect()
3305}
3306
3307/// A phone's alarm as the night's wind-down begins (`eventalarms`,
3308/// "day:night:…"): the night's own notice as a computer's minute says it
3309/// (`needs_now`), from the device you are at as who keeps the reminders
3310/// reads, never claimed from there (`share::looked`). Shown in Android's
3311/// notifications with "Close the whole day" while that night's review is to
3312/// give, and "Options…", each opening Sioul on its sheet or its question.
3313/// {"shown": true}; {"again_at": ms} rung early, or kept here only lately;
3314/// {} when it does not come.
3315pub(crate) fn decide_night(now: &Zoned) -> serde_json::Value {
3316    let stamp = now.timestamp().as_second();
3317    let starts: Vec<i64> = crate::hours::blocks(now).kept.iter().filter(|block| block.kind == "sleep").map(|block| block.start).collect();
3318    if let Some(start) = starts.iter().copied().filter(|start| *start > stamp).min()
3319        && start - stamp < 3600
3320    {
3321        return serde_json::json!({ "again_at": start * 1000 });
3322    }
3323    // Past the quarter of an hour its notice may come in (`Today::due`): nothing, nor asked again.
3324    if !starts.iter().any(|start| (0..15 * 60).contains(&(stamp - start))) {
3325        return serde_json::json!({});
3326    }
3327    let keeper = crate::share::looked("health", sioul_sync::lease::Rule::FollowsYou);
3328    if !keeper.mine {
3329        return serde_json::json!({});
3330    }
3331    if !keeper.settled {
3332        return serde_json::json!({ "again_at": (stamp + 60) * 1000 });
3333    }
3334    let Some(block) = needs_now(&load(), now).into_iter().find(|(block, heads_up)| !heads_up && block.kind == "sleep").map(|(block, _)| block) else {
3335        return serde_json::json!({});
3336    };
3337    let date = Timestamp::from_second(block.start).map(|t| t.to_zoned(now.time_zone().clone()).date()).unwrap_or(now.date());
3338    let offered = night_offered(&block, now);
3339    crate::eventalarms::day_note(&format!("day:night:{date}"), &name_of(&block), &clock(block.start, now.time_zone()), &offered, block.end.min(stamp + crate::reviews::PHONE_NOTICE_KEPT));
3340    serde_json::json!({ "shown": true })
3341}
3342
3343/// A day's block changed, that day only, as the page asks (JSON: its `date`,
3344/// "2026-10-07", today when none, and `sioul_core::needs::DayEdit`: key,
3345/// action, from, to…; `Needs::change_day`). The plan and the page follow;
3346/// a block moved is not said again: moving it never brings a nag. Returns
3347/// what went wrong, else "".
3348pub(crate) fn change_need(edit: &str) -> String {
3349    #[derive(Deserialize)]
3350    struct Asked {
3351        #[serde(default)]
3352        date: String,
3353        #[serde(flatten)]
3354        edit: DayEdit,
3355    }
3356    match serde_json::from_str::<Asked>(edit) {
3357        Ok(asked) => change_day(&asked.date, &asked.edit),
3358        Err(e) => e.to_string(),
3359    }
3360}
3361
3362fn change_day(date: &str, edit: &DayEdit) -> String {
3363    let now = Zoned::now();
3364    let date = if date.trim().is_empty() { Some(now.date()) } else { date.trim().parse::<Date>().ok() };
3365    let Some(date) = date else { return tr().text("need-times-wrong", None) };
3366    // Changed over the days as the file holds them now, under its lock, which the sharing takes too.
3367    let _ = days();
3368    let needs = load().needs;
3369    let held = held_on(date, &now);
3370    let changed = match Days::change_file(&Days::default_path(), now.date(), |days| needs.change_day(days, date, edit, &now, &held)) {
3371        Ok(changed) => changed,
3372        Err(e) => return e,
3373    };
3374    match changed {
3375        Ok(()) => {
3376            // A night changed, or its alarm: a phone's alarm at waking follows (`wake`).
3377            crate::wake::schedule();
3378            String::new()
3379        }
3380        Err(DayProblem::Past) => tr().text("need-past", None),
3381        Err(DayProblem::Times) => tr().text("need-times-wrong", None),
3382        Err(DayProblem::TooShort) => tr().text("need-too-short", None),
3383        Err(DayProblem::OtherDay) => tr().text("need-other-day", None),
3384        Err(DayProblem::Unknown) => say("setting-unknown-key", &[("key", edit.action.clone())]),
3385    }
3386}
3387
3388/// A day's block moved by a drag on the page's timeline (`change_need`'s
3389/// JSON: "move", or "times" for one of its ends), changed as "Move to…" and
3390/// "Change its times…" change it, then "Undo" offered for ten seconds, which
3391/// puts that day's block back as it was. Dropped where it was: nothing
3392/// changes, nothing to undo. Returns what went wrong, else "".
3393pub(crate) fn drag_need(qt: &QtThread, shared: &Arc<Shared>, edit: &str) -> String {
3394    #[derive(Deserialize)]
3395    struct Asked {
3396        #[serde(default)]
3397        date: String,
3398        #[serde(flatten)]
3399        edit: DayEdit,
3400    }
3401    let asked = match serde_json::from_str::<Asked>(edit) {
3402        Ok(asked) => asked,
3403        Err(e) => return e.to_string(),
3404    };
3405    let now = Zoned::now();
3406    let date = if asked.date.trim().is_empty() { Some(now.date()) } else { asked.date.trim().parse::<Date>().ok() };
3407    let Some(date) = date else { return tr().text("need-times-wrong", None) };
3408    let key = asked.edit.key.clone();
3409    let was = days().get(date, &key).cloned();
3410    let problem = change_day(&asked.date, &asked.edit);
3411    if !problem.is_empty() {
3412        return problem;
3413    }
3414    let after = days();
3415    if after.get(date, &key) == was.as_ref() {
3416        return String::new();
3417    }
3418    // Said as the day has it now: its name, its time kept.
3419    let zone = now.time_zone().clone();
3420    let line = blocks_now(&load().needs, &after, date, &now)
3421        .iter()
3422        .find(|k| k.key == key)
3423        .map(|k| say("drag-done", &[("what", name_of(k)), ("time", format!("{}–{}", clock(k.start, &zone), clock(k.end, &zone)))]))
3424        .unwrap_or_else(|| tr().text("drag-done-plain", None));
3425    crate::mail::offer_back(qt, shared, line, move |qt, shared| {
3426        // Put back over the days as the file holds them now, under its lock, which the sharing takes too.
3427        let _ = days();
3428        let problem = Days::change_file(&Days::default_path(), Zoned::now().date(), |days| match &was {
3429            Some(block) => days.change(date, &key, |b| *b = block.clone()),
3430            None => days.forget(date, &key),
3431        })
3432        .err()
3433        .unwrap_or_default();
3434        // A night put back: a phone's alarm at waking follows (`wake`).
3435        crate::wake::schedule();
3436        crate::work::show_work(qt, shared);
3437        show_health(qt, shared);
3438        problem
3439    });
3440    String::new()
3441}
3442
3443/// A block moved today only (the question, a notice's "Later"): by `minutes`
3444/// more ("Later": by the minutes set, again and again), or to start at `time`
3445/// ("13:30"). Returns what went wrong, else "".
3446pub(crate) fn move_today(key: &str, minutes: i64, time: &str) -> String {
3447    let action = if time.is_empty() { "later" } else { "move" };
3448    change_day("", &DayEdit { key: key.to_string(), action: action.to_string(), minutes, from: time.to_string(), ..DayEdit::default() })
3449}
3450
3451/// Today's meals, naps and night as they are now, as JSON: [{key, kind,
3452/// name, from, to, skipped, past}], for the question (`Interruption.qml`).
3453pub(crate) fn needs_today() -> String {
3454    let needs = load().needs;
3455    let now = Zoned::now();
3456    let days = days();
3457    let zone = now.time_zone().clone();
3458    let stamp = now.timestamp().as_second();
3459    let rows: Vec<serde_json::Value> = blocks_now(&needs, &days, now.date(), &now)
3460        .iter()
3461        .map(|k| serde_json::json!({ "key": k.key, "kind": k.kind, "name": name_of(k), "from": clock(k.start, &zone), "to": clock(k.end, &zone), "skipped": days.get(now.date(), &k.key).is_some_and(|c| c.quiet), "past": k.end <= stamp }))
3462        .collect();
3463    json(&rows)
3464}
3465
3466/// The minutes "Later" moves a block by.
3467pub(crate) fn later_minutes() -> u32 {
3468    load().needs.later
3469}
3470
3471/// A block without notices today, or with them again: kept free all the same.
3472pub(crate) fn skip_today(key: &str, skip: bool) -> String {
3473    change_day("", &DayEdit { key: key.to_string(), action: if skip { "quiet" } else { "loud" }.to_string(), ..DayEdit::default() })
3474}
3475
3476/// The usual meals, naps and night as the page's settings set them, as
3477/// JSON: the settings, the usual names, the long gaps between meals.
3478pub(crate) fn needs_page() -> String {
3479    let needs = load().needs;
3480    serde_json::json!({
3481        "needs": needs,
3482        "usual": {
3483            "meals": (0..needs.meals.len().max(3) + 1).map(|i| usual_name("meal", i)).collect::<Vec<_>>(),
3484            "nap": usual_name("nap", 0),
3485            "sleep": usual_name("sleep", 0),
3486        },
3487        "gaps": needs.long_gaps().into_iter().map(|(from, to)| say("need-gap", &[("from", from), ("to", to)])).collect::<Vec<_>>(),
3488        // The alarm at waking: its weekdays' names, the next ring, what Android refuses it.
3489        "wake": crate::wake::status(&needs),
3490    })
3491    .to_string()
3492}
3493
3494/// Where a list lost one block: the place taken out, when the rest is the same.
3495fn removed_at(old: &[sioul_core::needs::Block], new: &[sioul_core::needs::Block]) -> Option<usize> {
3496    if new.len() + 1 != old.len() {
3497        return None;
3498    }
3499    let at = (0..old.len()).find(|&i| i == new.len() || old[i] != new[i])?;
3500    (old[at + 1..] == new[at..]).then_some(at)
3501}
3502
3503/// The usual meals, naps and night saved as the settings give them; a meal
3504/// or a nap taken out, the days' changes of those after it follow them to
3505/// their new place. Returns what went wrong, else "".
3506pub(crate) fn save_needs(edit: &str, shown: &str) -> String {
3507    let needs: Needs = match serde_json::from_str(edit) {
3508        Ok(needs) => needs,
3509        Err(e) => return e.to_string(),
3510    };
3511    // What the page showed: its change set over the settings as the file holds them now.
3512    let shown: Option<Needs> = serde_json::from_str(shown).ok();
3513    let removed = change_health(|health| {
3514        let needs = shown.as_ref().map_or_else(|| needs.clone(), |shown| needs.rebased(shown, &health.needs));
3515        let removed = (removed_at(&health.needs.meals, &needs.meals), removed_at(&health.needs.naps, &needs.naps));
3516        health.needs = needs;
3517        Ok(removed)
3518    });
3519    let (meal, nap) = match removed {
3520        Ok(removed) => removed,
3521        Err(e) => return e,
3522    };
3523    if meal.is_none() && nap.is_none() {
3524        return String::new();
3525    }
3526    // A meal or a nap taken out: the days' changes of those after it move up a place.
3527    Days::change_file(&Days::default_path(), Zoned::now().date(), |days| {
3528        if let Some(at) = meal {
3529            days.removed_usual("meal", at);
3530        }
3531        if let Some(at) = nap {
3532            days.removed_usual("nap", at);
3533        }
3534    })
3535    .err()
3536    .unwrap_or_default()
3537}
3538
3539// ---------------------------------------------------------------- what each device did to remind a dose
3540
3541/// What each device did to remind each dose (`sioul_core::dose_reminders`),
3542/// as read here; none when the file cannot be trusted (repaired first, the
3543/// doses due before then said not known for a day: `repair_told`). None is
3544/// never read as "nothing done": a phone's alarm then says the doubt.
3545fn told() -> Option<DoseReminders> {
3546    let path = DoseReminders::default_path();
3547    match DoseReminders::read(&path) {
3548        Ok(told) => Some(told),
3549        Err(_) => {
3550            repair_told();
3551            DoseReminders::read(&path).ok()
3552        }
3553    }
3554}
3555
3556/// A reminders' file that cannot be trusted, as `repair_records` does the
3557/// records: read again from every device's at the next exchange, kept aside
3558/// in the same step; the doses due before said not known for a day
3559/// (`write_record_doubt`).
3560fn repair_told() {
3561    let now = Timestamp::now().as_second();
3562    let path = DoseReminders::default_path();
3563    let set_aside = || sioul_core::health::HealthState::set_aside(&path, now);
3564    let aside = if crate::share::on() {
3565        match crate::share::rebuild_dose_reminders(set_aside) {
3566            Ok(aside) => aside,
3567            Err(_) => {
3568                write_record_doubt(now, "");
3569                return;
3570            }
3571        }
3572    } else {
3573        set_aside()
3574    };
3575    write_record_doubt(now, &aside.ok().flatten().map(|p| p.display().to_string()).unwrap_or_default());
3576}
3577
3578/// This computer's entry for the dose `key` changed by `change`; false when
3579/// it could not be written.
3580fn tell_mine(key: &str, change: impl FnOnce(&mut Tried)) -> bool {
3581    let me = this_device();
3582    let now = Timestamp::now().as_second();
3583    let written = DoseReminders::update(&DoseReminders::default_path(), now, &me, |all| change(all.entry(key, &me))).is_ok();
3584    if written {
3585        crate::devices::recorded();
3586    }
3587    written
3588}
3589
3590/// This computer promises no notice of the dose `key` until `until`
3591/// (`dose_reminders::NEVER`: none again); a longer promise kept.
3592fn promise(key: &str, until: i64) -> bool {
3593    tell_mine(key, |mine| mine.quiet = mine.quiet.max(until))
3594}
3595
3596/// What this computer does for a dose at its tick (docs/health.md, "What
3597/// became of a reminder"; review of battery part C, the fifth review).
3598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3599enum Plan {
3600    /// Nothing now: shown, promised quiet, or not this computer's to remind.
3601    Not,
3602    /// Its first reminder, plainly unless the devices leave a doubt: this
3603    /// computer kept the doses at the dose's time, and nothing of it was
3604    /// recorded here before.
3605    First,
3606    /// A phone's alarm due: promised quiet until then, nothing now.
3607    Quiet(i64),
3608    /// A reminder shown late, the doubt first: its first try did not go out
3609    /// (`Late::Retry`), went and was not known shown (`Late::Unconfirmed`,
3610    /// by an earlier Sioul here), or another device may have reminded it
3611    /// (`None`: this computer did not keep the doses then).
3612    Late(Option<sioul_sync::notify::Late>),
3613}
3614
3615/// How long this computer's first reminder of a dose may wait for news, its
3616/// window as its first: until three minutes before a phone's alarm while a
3617/// phone shares the doses (`LATE_UNTIL`), else ten minutes (`WAIT_FOR_NEWS`).
3618fn first_wait(alarms: bool) -> i64 {
3619    if alarms { LATE_UNTIL } else { WAIT_FOR_NEWS }
3620}
3621
3622/// The plan for a dose to remind from `since` (its time, or the waking it
3623/// waited for), at `stamp`: `own`, this computer's entry for it (`read`: the
3624/// file read here); `first_ok`, whether this computer may be its first
3625/// (`first_here`: the phones' word on its opening, before `since`); `alarms`,
3626/// a phone may remind it by its own alarm ten minutes on: none from here
3627/// around it, promised. The rule: a first reminder is plain only where it is
3628/// the dose's first; every other says the doubt first; none twice in the
3629/// same words.
3630fn plan(own: Option<Tried>, read: bool, first_ok: bool, since: i64, stamp: i64, alarms: bool) -> Plan {
3631    use sioul_sync::notify::Late;
3632    let first = first_ok && stamp < since + first_wait(alarms);
3633    let quiet = alarms && stamp < since + sioul_core::dose_reminders::QUIET_AFTER;
3634    // The file not read: a first try, else quiet around a phone's alarm (nothing
3635    // promised can be read or written here), then the doubt first (the sixth review's L1).
3636    if !read {
3637        return match own {
3638            None if first => Plan::First,
3639            _ if quiet => Plan::Quiet(since + sioul_core::dose_reminders::QUIET_AFTER),
3640            _ => Plan::Late(None),
3641        };
3642    }
3643    match own {
3644        Some(own) if own.shown > 0 || own.quiet >= stamp => Plan::Not,
3645        Some(own) if own.maybe > 0 || own.unshown > 0 => {
3646            if quiet && stamp >= since + LATE_UNTIL {
3647                Plan::Quiet(since + sioul_core::dose_reminders::QUIET_AFTER)
3648            } else if own.maybe > 0 {
3649                Plan::Late(Some(Late::Unconfirmed))
3650            } else {
3651                Plan::Late(Some(Late::Retry))
3652            }
3653        }
3654        // A promise of its own, over: not its first.
3655        Some(_) if quiet => Plan::Quiet(since + sioul_core::dose_reminders::QUIET_AFTER),
3656        Some(_) => Plan::Late(None),
3657        None if first => Plan::First,
3658        None if quiet => Plan::Quiet(since + sioul_core::dose_reminders::QUIET_AFTER),
3659        None => Plan::Late(None),
3660    }
3661}
3662
3663/// Whether this computer keeps the doses now, as the claims read.
3664fn kept_here() -> bool {
3665    crate::share::looked("health", sioul_sync::lease::Rule::FollowsYou).mine
3666}
3667
3668/// The doses kept elsewhere a moment: this dose's tries end here for now,
3669/// nothing promised (never "none again": the sixth review's M3), and it is
3670/// taken out of this session's sent ones, so that the tick works its plan
3671/// out again should the doses come back here within its grace.
3672fn let_go(key: &str) {
3673    if let Ok(mut sent) = SENT.lock() {
3674        sent.remove(key);
3675    }
3676}
3677
3678/// Whether a dose is still to remind, as read here now: neither marked nor
3679/// answered anywhere, nor reminded anywhere (the marks' record, or a
3680/// device's notice shown).
3681fn still_to_remind(key: &str) -> bool {
3682    let _held = state_held();
3683    let state = record();
3684    late_reminder_wanted(&state, &records(), key) && !told().is_some_and(|told| told.shown(key))
3685}
3686
3687/// The words of a dose's reminder shown late on this computer, the doubt
3688/// first, always (`late_said`); none when it is no longer to remind
3689/// (`still_to_remind`), or its grace is over (`GRACE_MINUTES` from `since`:
3690/// its question on the past takes it up).
3691fn late_words(key: &str, since: i64, why: Option<sioul_sync::notify::Late>) -> Option<(String, String)> {
3692    if !still_to_remind(key) {
3693        return None;
3694    }
3695    let now = Zoned::now();
3696    let stamp = now.timestamp().as_second();
3697    if stamp >= since + GRACE_MINUTES * 60 {
3698        return None;
3699    }
3700    let start = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
3701    let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
3702    let dose = load().doses(&start, &end).into_iter().find(|d| d.key == key)?;
3703    Some(late_said(&dose, &know(), stamp, since, why))
3704}
3705
3706/// A late reminder's words: "Check first: …", then its time and why Sioul
3707/// cannot tell, why it is late first: its first try could not be shown in
3708/// time (`Late::Retry`), it was shown and not confirmed in time
3709/// (`Late::Unconfirmed`), or another device may have reminded it (`None`);
3710/// then what the devices leave unknown, from `since`.
3711fn late_said(dose: &sioul_core::health::Dose, knowledge: &Knowledge, now: i64, since: i64, why: Option<sioul_sync::notify::Late>) -> (String, String) {
3712    use sioul_sync::notify::Late;
3713    let due = dose.at.timestamp().as_second();
3714    let late = match why {
3715        Some(Late::Retry) => say("dose-doubt-late", &[("when", moment_said(due))]),
3716        Some(Late::Unconfirmed) => say("dose-doubt-unconfirmed", &[("when", moment_said(due))]),
3717        None => say("dose-doubt-other", &[("when", moment_said(since))]),
3718    };
3719    let doubt = doubt_unread_with(knowledge, due, since, now, None, Some(&late));
3720    (say("dose-check-title", &[("dose", named(dose))]), format!("{}. {doubt}", dose.at.strftime("%H:%M")))
3721}
3722
3723/// A dose's reminder as this computer records it, for the notice's thread
3724/// (`sioul_sync::notify::DoseRecord`): this device's entry in the dose's
3725/// reminders, written before each notice goes, and the marks' record for an
3726/// older Sioul (`reminded`, `unshown`).
3727struct DoseNotes {
3728    key: String,
3729    /// When it was to be reminded: its time, or the waking it waited for.
3730    since: i64,
3731    /// The entry's `maybe` before the notice last recorded going: put back if refused.
3732    before: Option<i64>,
3733}
3734
3735impl sioul_sync::notify::DoseRecord for DoseNotes {
3736    fn going(&mut self, going: sioul_sync::notify::Going) -> sioul_sync::notify::Say {
3737        use sioul_sync::notify::{Going, Say};
3738        // The first: the tick decided it a moment ago (its record's lock is
3739        // its own until it waits for this outcome: not taken again here).
3740        let words = match going {
3741            Going::First => Some(None),
3742            // The doses kept elsewhere now: let go, nothing promised (M3).
3743            Going::Late(_) if !kept_here() => {
3744                let_go(&self.key);
3745                return Say::Nothing;
3746            }
3747            Going::Late(why) => late_words(&self.key, self.since, Some(why)).map(Some),
3748        };
3749        let Some(words) = words else {
3750            promise(&self.key, sioul_core::dose_reminders::NEVER);
3751            return Say::Nothing;
3752        };
3753        let now = Timestamp::now().as_second();
3754        let mut before = 0;
3755        let written = tell_mine(&self.key, |mine| {
3756            before = mine.maybe;
3757            mine.maybe = now;
3758        });
3759        // Not recorded: it goes all the same (late, the doubt first): a phone
3760        // reads no word of this computer's on the dose, which is a doubt
3761        // there (the sixth review's L1).
3762        self.before = written.then_some(before);
3763        match words {
3764            None => Say::Own,
3765            Some((title, body)) => Say::Words(title, body),
3766        }
3767    }
3768
3769    fn refused(&mut self) {
3770        if let Some(before) = self.before.take() {
3771            tell_mine(&self.key, |mine| mine.maybe = before);
3772        }
3773    }
3774
3775    fn shown(&mut self) {
3776        let now = Timestamp::now().as_second();
3777        tell_mine(&self.key, |mine| {
3778            if mine.shown == 0 {
3779                mine.shown = now;
3780            }
3781        });
3782        let _held = state_held();
3783        let _ = change(|record| late_reminded(record, &self.key, now));
3784    }
3785
3786    fn unshown(&mut self) {
3787        let now = Timestamp::now().as_second();
3788        tell_mine(&self.key, |mine| {
3789            if mine.unshown == 0 {
3790                mine.unshown = now;
3791            }
3792        });
3793        let _held = state_held();
3794        let _ = change(|record| {
3795            if !record.reminded.contains_key(&self.key) {
3796                record.unshown.entry(self.key.clone()).or_insert(now);
3797            }
3798        });
3799    }
3800
3801    fn pause(&mut self, until: i64) -> bool {
3802        if !kept_here() {
3803            let_go(&self.key);
3804            false
3805        } else if still_to_remind(&self.key) {
3806            // Promised, or not written: quiet all the same (the thread sleeps through it).
3807            promise(&self.key, until);
3808            true
3809        } else {
3810            promise(&self.key, sioul_core::dose_reminders::NEVER);
3811            false
3812        }
3813    }
3814
3815    fn over(&mut self) {
3816        promise(&self.key, sioul_core::dose_reminders::NEVER);
3817    }
3818
3819    fn settled(&mut self) -> bool {
3820        let _held = state_held();
3821        let state = record();
3822        state.taken.contains_key(&self.key) || state.not_taken.contains_key(&self.key) || sioul_core::doses::answered(&self.key, &state, &records()) != Answered::Open
3823    }
3824}
3825
3826/// Whether a dose's reminder may still be shown: neither marked here (taken,
3827/// or said not taken) nor answered on another device, nor reminded since
3828/// (another device took it up: a phone's alarm rang): what the phone did is
3829/// never contradicted, and nothing is ever said of it but a reminder.
3830fn late_reminder_wanted(state: &HealthState, records: &DoseRecords, key: &str) -> bool {
3831    !state.taken.contains_key(key) && !state.not_taken.contains_key(key) && !state.reminded.contains_key(key) && sioul_core::doses::answered(key, state, records) == Answered::Open
3832}
3833
3834/// `shown_late`'s record: a reminder at `now`, unless reminded or marked
3835/// meanwhile (the earlier word kept); the `unshown` of its first try stays,
3836/// a reminder outweighing it (`HealthState::reminder_unshown`).
3837fn late_reminded(record: &mut HealthState, key: &str, now: i64) {
3838    if !record.reminded.contains_key(key) && !record.taken.contains_key(key) && !record.not_taken.contains_key(key) {
3839        record.reminded.insert(key.to_string(), now);
3840    }
3841}
3842
3843/// Each minute: a dose due is reminded once, quietly, with "Taken"; a refill
3844/// or a renewal coming becomes a task in a list kept on this computer.
3845/// How long a gap between two of this computer's ticks (each minute) makes
3846/// its coming back a new opening (`devices::resumed`): three minutes, two
3847/// ticks and their exchanges missed (asleep, the machine frozen), so that a
3848/// laptop woken has no phone's word on it until a phone read it awake (the
3849/// seventh review's C1).
3850const WAKE_GAP: i64 = 3 * 60;
3851
3852/// This computer's last tick (Unix seconds; 0 none yet).
3853static LAST_TICK: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
3854
3855/// Whether a tick at `now`, after one at `last`, comes back from a gap
3856/// (`WAKE_GAP`); never the first tick, nor a clock set back.
3857fn woke_from_gap(last: i64, now: i64) -> bool {
3858    last > 0 && now - last >= WAKE_GAP
3859}
3860
3861pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {
3862    // Back from a gap (asleep): a new opening, its word asked anew.
3863    let now_tick = Timestamp::now().as_second();
3864    if !cfg!(target_os = "android") && woke_from_gap(LAST_TICK.swap(now_tick, std::sync::atomic::Ordering::SeqCst), now_tick) {
3865        crate::devices::resumed();
3866    }
3867    let Some(_ticking) = crate::backend::one_at_a_time(&TICKING) else { return };
3868    let health = load();
3869    if health.medicines.is_empty() && health.prescriptions.is_empty() {
3870        // No medicines: the pause to move, meals and rest, from the computer you are at.
3871        let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
3872        let (keeper, _) = crate::share::keeper("health", sioul_sync::lease::Rule::FollowsYou, active, false);
3873        movement_tick(qt, shared, &health, &Zoned::now(), keeper.mine);
3874        if keeper.mine && keeper.settled {
3875            needs_tick(qt, shared, &health, &Zoned::now());
3876        }
3877        // On a phone, the alarm at waking (`wake`; with medicines, `alarms::schedule` below hands it).
3878        crate::wake::schedule();
3879        return;
3880    }
3881    // One computer reminds: the one you are at, once it has been so long
3882    // enough for the others to know (`sioul_sync::lease`). A dose reminded
3883    // twice could be taken twice.
3884    let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
3885    let (keeper, claim_failed) = crate::share::keeper("health", sioul_sync::lease::Rule::FollowsYou, active, false);
3886    let _held = state_held();
3887    let state = record();
3888    // A dose taken late moved the next ones; the medicine, changed meanwhile
3889    // on another device, came back with its start as it was: moved again,
3890    // and sent (review of 5 October 2026, F23).
3891    let health = if health.clone().mend_shifts(&state) && change_health(|h| Ok(h.mend_shifts(&state))).is_ok() { load() } else { health };
3892    let knowledge = know();
3893    let now = Zoned::now();
3894    let stamp = now.timestamp().as_second();
3895    // Asleep inside this very tick (its prelude): a new opening all the same (the eighth review's L4).
3896    if !cfg!(target_os = "android") && woke_from_gap(now_tick, stamp) {
3897        crate::devices::resumed();
3898    }
3899    // Each dose due lately, answered nowhere this device knows: its record
3900    // opened, not taken yet (the canary), for your devices to answer.
3901    open_due(&health, &state, &now);
3902    if let Ok(mut there) = REMINDED_THERE.lock() {
3903        *there = if keeper.mine { String::new() } else { say("health-reminded-there", &[("computer", keeper.name.clone())]) };
3904    }
3905    movement_tick(qt, shared, &health, &now, keeper.mine);
3906    if keeper.mine && keeper.settled {
3907        needs_tick(qt, shared, &health, &now);
3908    }
3909    let mut reminded: Vec<String> = Vec::new();
3910    let mut unshown: Vec<String> = Vec::new();
3911    let mut maybe: Vec<String> = Vec::new();
3912    // A phone may remind the doses by its own alarm, ten minutes on: no
3913    // notice from here meanwhile, promised, so that it may know it is the
3914    // first; one in the folder, counted or not (the sixth review's R3).
3915    let alarms = phone_alarms(&knowledge);
3916    // The sleep or pause the doses waited for, ended now: their reminders count from waking (`news_from`).
3917    let woken = crate::hours::woken();
3918    // What each device did to remind each dose (none: the file does not read here).
3919    let told_now = told();
3920    let answers = records();
3921    let me = this_device();
3922    // The handshake (`first_here`): alone (sharing off, or no other device in
3923    // the folder); else this opening written into the folder, the claim and
3924    // the records written, and every phone's word that it read this opening.
3925    let configured = crate::share::configured();
3926    let alone = !configured || (crate::share::on() && knowledge.peers.is_empty());
3927    // Sharing set up, its key not read: the others not known here, a phone among them maybe (R2).
3928    let alarms = alarms || (configured && !crate::share::on());
3929    let start = crate::devices::published_start();
3930    let told_all = crate::share::on() && !claim_failed && start > 0;
3931    // Each phone's word as its newest copy read here says it, never an older one put back (the ninth review's W1).
3932    let acked = told_all.then(|| acknowledged_since(&knowledge, start, &acknowledgements(&me, &newest_entries()), stamp)).flatten();
3933    let lead = word_lead(&knowledge);
3934    // A device that stopped sharing: its entries in the reminders taken out (L5); one only
3935    // forgotten here keeps them until they are a month old (`STALE_DAYS`: the eighth review's L6).
3936    let gone: Vec<String> = knowledge.peers.iter().filter(|p| p.said.as_ref().is_some_and(|s| s.left)).map(|p| p.id.clone()).collect();
3937    if told_now.as_ref().is_some_and(|told| told.holds_any(&gone)) {
3938        let _ = DoseReminders::update(&DoseReminders::default_path(), stamp, &me, |all| all.forget(&gone));
3939    }
3940    // Each dose's reminder under way, what became of it waited for below.
3941    let mut sending: Vec<(String, sioul_sync::notify::Sending)> = Vec::new();
3942    // On a phone, Android's alarm clock reminds, Sioul shown or not (`alarms`).
3943    crate::alarms::schedule();
3944    // Asleep with "Doses during sleep: stay silent" (Settings ▸ Reminders and
3945    // notifications), they wait for waking; by default they come, asleep or not.
3946    if !cfg!(target_os = "android") && !crate::hours::doses_silent() {
3947        // Those that waited come at waking, once (more than half an hour late, Taken asks when).
3948        let window = crate::hours::woke_from().map_or(GRACE_MINUTES, |since| GRACE_MINUTES.max((stamp - since) / 60 + 1));
3949        for dose in state.to_remind(&health, &now, window) {
3950            if SENT.lock().is_ok_and(|sent| sent.contains(&dose.key)) {
3951                continue;
3952            }
3953            let due = dose.at.timestamp().as_second();
3954            let since = news_from(due, woken);
3955            // Shown on a device, or answered anywhere read here: nothing to remind.
3956            if told_now.as_ref().is_some_and(|told| told.shown(&dose.key)) || sioul_core::doses::answered(&dose.key, &state, &answers) != Answered::Open {
3957                continue;
3958            }
3959            let own = told_now.as_ref().and_then(|told| told.of(&dose.key, &me));
3960            let quiet_until = since + sioul_core::dose_reminders::QUIET_AFTER;
3961            // Not this computer's to remind (the doses kept elsewhere, or kept
3962            // here too lately): a phone sharing them is promised none from
3963            // here until after its alarm (docs/health.md, "What became of a reminder").
3964            if !(keeper.mine && keeper.settled) {
3965                if alarms && told_now.is_some() && stamp < quiet_until && own.is_none_or(|own| own.maybe == 0 && own.quiet < quiet_until) {
3966                    promise(&dose.key, quiet_until);
3967                }
3968                continue;
3969            }
3970            let first_ok = first_here(alone, told_all, keeper.since, acked, since, lead);
3971            let (title, body) = match plan(own, told_now.is_some(), first_ok, since, stamp, alarms) {
3972                Plan::Not => continue,
3973                Plan::Quiet(until) => {
3974                    promise(&dose.key, until);
3975                    continue;
3976                }
3977                Plan::First => {
3978                    // Not known whether it was taken on another device: news is
3979                    // waited for, within its window as the first (`first_wait`);
3980                    // then it is reminded all the same, the doubt said first.
3981                    // Another device's notice sent and not known shown (two
3982                    // computers each keeping the doses, the sync slow): said first too.
3983                    let other = told_now.as_ref().and_then(|told| told.maybe_by(&dose.key, &me)).map(|(device, at)| say("dose-doubt-maybe", &[("name", device_word(&named_by(&knowledge, &device), tr())), ("when", moment_said(at))]));
3984                    let doubt = doubt_unread_with(&knowledge, due, since, stamp, None, other.as_deref());
3985                    if !doubt.is_empty() && other.is_none() && stamp + 60 < since + first_wait(alarms) {
3986                        continue;
3987                    }
3988                    let named = named(&dose);
3989                    if doubt.is_empty() { (named, dose.at.strftime("%H:%M").to_string()) } else { (say("dose-check-title", &[("dose", named)]), format!("{}. {doubt}", dose.at.strftime("%H:%M"))) }
3990                }
3991                Plan::Late(None) => {
3992                    // Not its first: why another device may have reminded it, each named.
3993                    let elsewhere = match sioul_core::dose_reminders::others(&dose.key, since, stamp, &me, false, told_now.as_ref(), &knowledge.peers, PULLED.load(std::sync::atomic::Ordering::Relaxed)) {
3994                        Others::Doubt(elsewhere) => elsewhere,
3995                        Others::First | Others::Reminded => Vec::new(),
3996                    };
3997                    let untold = (configured && !told_all).then(|| say("dose-doubt-untold", &[]));
3998                    let reasons: Vec<String> = untold.into_iter().chain((!elsewhere.is_empty()).then(|| elsewhere_said(&elsewhere, since, tr()))).collect();
3999                    // Never plain: not its first (the seventh review's C2); none to name, said all the same.
4000                    let first = if reasons.is_empty() { say("dose-doubt-other", &[("when", moment_said(since))]) } else { joined(reasons, tr()) };
4001                    let doubt = doubt_unread_with(&knowledge, due, since, stamp, None, Some(&first));
4002                    (say("dose-check-title", &[("dose", named(&dose))]), format!("{}. {doubt}", dose.at.strftime("%H:%M")))
4003                }
4004                Plan::Late(why) => late_said(&dose, &knowledge, stamp, since, why),
4005            };
4006            if let Ok(mut sent) = SENT.lock() {
4007                sent.insert(dose.key.clone());
4008            }
4009            let key = dose.key.clone();
4010            let (qt_taken, shared_taken) = (qt.clone(), Arc::clone(shared));
4011            let taken: Box<dyn FnOnce() + Send> = Box::new(move || {
4012                // Pressed more than half an hour late: when it was taken is asked, in the window.
4013                if is_late(&key) {
4014                    let _ = qt_taken.queue(move |mut sioul| sioul.as_mut().reminder_opened(QString::from("dose"), QString::default(), QString::from(&key)));
4015                    return;
4016                }
4017                let problem = set_taken(&key, true);
4018                if !problem.is_empty() {
4019                    tell(&qt_taken, &shared_taken, problem);
4020                }
4021                // Your other computers know at once.
4022                crate::share::exchange(&qt_taken, &shared_taken);
4023            });
4024            // Each notice recorded before it goes (`DoseNotes`), what became of
4025            // it after (below, and on its thread): not gone out, tried again,
4026            // the doubt first, until its grace's end, none while a phone's
4027            // alarm is due (promised); sent and not known shown, listened for.
4028            let instant = |at: i64| std::time::Instant::now() + std::time::Duration::from_secs(u64::try_from(at - stamp).unwrap_or(0));
4029            let quiet = (alarms && stamp < quiet_until).then(|| (instant(since + LATE_UNTIL), instant(quiet_until), quiet_until));
4030            let again = sioul_sync::notify::Again { end: instant(since + GRACE_MINUTES * 60), quiet, record: Box::new(DoseNotes { key: dose.key.clone(), since, before: None }) };
4031            sending.push((dose.key.clone(), sioul_sync::notify::remind_dose(&title, &body, (tr().text("health-taken", None), taken), again)));
4032        }
4033    }
4034    // Doses due while Sioul ran nowhere, or whose reminder could not be shown:
4035    // asked about once, as a question on the past, after your other computers
4036    // were heard from (a dose marked there comes first), and only where you
4037    // are; its title says which (`missed_title`); what is not known, said.
4038    let heard = !crate::share::on() || crate::share::last_exchange().is_some();
4039    // On a phone, without notifications, the Porch asks it (`missed`).
4040    // Asleep with doses staying silent, it waits for waking too.
4041    if heard && keeper.mine && !cfg!(target_os = "android") && !crate::hours::doses_silent() && !ASKED.swap(true, std::sync::atomic::Ordering::Relaxed) {
4042        let missed = state.unanswered(&health, &now, MISSED_HOURS, GRACE_MINUTES);
4043        if !missed.is_empty() {
4044            let names: Vec<String> = missed.iter().map(|d| format!("{} {}", d.name, d.at.strftime("%H:%M"))).collect();
4045            let qt_open = qt.clone();
4046            let open: Box<dyn FnOnce() + Send> = Box::new(move || {
4047                let _ = qt_open.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("porch"), QString::default(), QString::default()));
4048            });
4049            let doubts: Vec<String> = missed.iter().map(|d| doubt_of(&knowledge, d.at.timestamp().as_second(), d.at.timestamp().as_second(), stamp)).filter(|d| !d.is_empty()).collect();
4050            let mut body = say("health-missed-body", &[("doses", names.join(", "))]);
4051            if let Some(doubt) = doubts.first() {
4052                body = format!("{body} {doubt}");
4053            }
4054            if let Err(e) = sioul_sync::notify::remind(&missed_title(&missed, &state, &told_now.clone().unwrap_or_default(), tr()), &body, Some((tr().text("health-missed-open", None), open))) {
4055                tell(qt, shared, e);
4056            }
4057        }
4058    }
4059    // Errands: a task each, the day it comes into view, made once, in the list
4060    // the phone has; those made on this computer before move there, once.
4061    let target = errands_list(&health);
4062    let mut made: Vec<(String, String)> = Vec::new();
4063    for errand in health.errands().into_iter().filter(|e| e.day <= now.date().checked_add(Span::new().days(7)).unwrap_or(now.date())) {
4064        if state.errands.contains_key(&errand.key) {
4065            continue;
4066        }
4067        let title = say(if errand.kind == ErrandKind::Refill { "health-errand-refill" } else { "health-errand-renew" }, &[("title", errand.title.clone())]);
4068        let edit = TaskEdit { title, start: errand.day.to_string(), categories: vec![tr().text("health-category", None)], estimate: 30, ..TaskEdit::default() };
4069        let task = match &target {
4070            Some(list) => work::create_task(qt, shared, &edit, list),
4071            None => work::local_task(qt, shared, &tr().text("health-list", None), &edit),
4072        };
4073        if let Ok(uid) = task {
4074            made.push((errand.key, uid));
4075        }
4076    }
4077    if let Some(list) = target.as_ref().filter(|l| !l.starts_with(&format!("{}/", sioul_core::vdir::LOCAL))) {
4078        let loaded = work::loaded(shared);
4079        for uid in state.errands.values().chain(made.iter().map(|(_, uid)| uid)) {
4080            let here = loaded.tasks.iter().find(|t| &t.uid == uid).is_some_and(|t| t.status.is_open() && t.list_id.starts_with(&format!("{}/", sioul_core::vdir::LOCAL)));
4081            if here {
4082                let _ = work::move_task(qt, shared, uid, list, true);
4083            }
4084        }
4085    }
4086    // What became of each dose's reminder: waited for here, ten seconds at most
4087    // in all (`notify::FIRST_TRY`: its whole first try fits in it), the record
4088    // let go meanwhile (the page and the notifications' buttons mark doses
4089    // meanwhile). Shown: a reminder. Surely not shown (no connection, no
4090    // notification server, nothing sent in time): `unshown`, the phone's alarm
4091    // rings and the question on the past asks about it. Sent and not known
4092    // shown (no answer in time, the bus stalled under it, this wait over):
4093    // `maybe`, the phone's alarm rings with the doubt first (review of battery
4094    // part C, the third and fourth reviews' H1).
4095    drop(_held);
4096    let deadline = std::time::Instant::now() + sioul_sync::notify::FIRST_TRY;
4097    for (key, sent) in sending {
4098        match sent.outcome(deadline.saturating_duration_since(std::time::Instant::now())) {
4099            sioul_sync::notify::Outcome::Shown => reminded.push(key),
4100            sioul_sync::notify::Outcome::Unshown(e) => {
4101                unshown.push(key);
4102                tell(qt, shared, e);
4103            }
4104            sioul_sync::notify::Outcome::Maybe(e) => {
4105                maybe.push(key);
4106                tell(qt, shared, e);
4107            }
4108            sioul_sync::notify::Outcome::Gone => {}
4109        }
4110    }
4111    let _held = state_held();
4112    // What this minute did, written over nothing anyone else wrote meanwhile.
4113    let problem = change(|record| note_minute(record, &reminded, &unshown, &maybe, &made, stamp));
4114    if !problem.is_empty() && (!reminded.is_empty() || !unshown.is_empty() || !maybe.is_empty() || !made.is_empty()) {
4115        tell(qt, shared, problem);
4116    }
4117}
4118
4119#[cfg(test)]
4120mod tests {
4121    use super::*;
4122    use sioul_sync::lease::Claim;
4123
4124    fn claim(renewed: i64, wrote: Option<(u32, u64)>, closed: bool) -> Claim {
4125        Claim { computer: "laptop-id".into(), name: "laptop".into(), since: 0, renewed, until: renewed + 300, active: renewed, taken: 0, wrote, closed, pad: String::new() }
4126    }
4127
4128    fn heard_up_to(n: u64) -> sioul_sync::share::Heard {
4129        sioul_sync::share::Heard { read: [("laptop-id".to_string(), (1, n))].into(), broken: Default::default() }
4130    }
4131
4132    #[test]
4133    fn what_the_others_claims_teach() {
4134        let mut kept = Peers::default();
4135        // Seen for the first time, an old claim: known until then, but no delay measured from it.
4136        learn(&mut kept, &[claim(1_000, Some((1, 5)), false)], &[], &heard_up_to(5), 9_000);
4137        let peer = &kept.peers["laptop-id"].peer;
4138        assert_eq!((peer.known_until, peer.delay, peer.name.as_str()), (1_000, None, "laptop"));
4139        // Watching, each new claim times how late it came.
4140        learn(&mut kept, &[claim(9_030, Some((1, 6)), false)], &[], &heard_up_to(6), 9_060);
4141        learn(&mut kept, &[claim(9_090, Some((1, 6)), false)], &[], &heard_up_to(6), 9_120);
4142        assert_eq!(kept.peers["laptop-id"].peer.delay, Some(30));
4143        // A record it wrote not read here yet: known only until the claim before.
4144        learn(&mut kept, &[claim(9_150, Some((1, 9)), false)], &[], &heard_up_to(6), 9_180);
4145        assert_eq!(kept.peers["laptop-id"].peer.known_until, 9_090);
4146        // Read, and it says it closed: known until then, closed.
4147        learn(&mut kept, &[claim(9_200, Some((1, 9)), true)], &[], &heard_up_to(9), 9_240);
4148        let peer = &kept.peers["laptop-id"].peer;
4149        assert!(peer.closed && peer.known_until == 9_200);
4150        // Its news comes within a minute: a dose due after it closed is known.
4151        assert!(sioul_core::health::doubts(9_600, 9_700, None, std::slice::from_ref(peer)).is_empty());
4152        // An older Sioul that never says how far it wrote: heard, never known.
4153        let mut old = Peers::default();
4154        learn(&mut old, &[claim(9_000, None, false)], &[], &heard_up_to(99), 9_030);
4155        assert_eq!(old.peers["laptop-id"].peer.known_until, 0);
4156        assert_eq!(sioul_core::health::doubts(9_010, 9_030, None, &[old.peers["laptop-id"].peer.clone()]).len(), 1);
4157    }
4158
4159    /// F13: an older Sioul's claim dated ahead of this device's clock (its
4160    /// clock ten minutes fast) is never known past now: what it may mark in
4161    /// those ten minutes is not known here yet. A little ahead: until now.
4162    #[test]
4163    fn a_claim_from_a_clock_ahead_is_never_known_past_now() {
4164        let mut kept = Peers::default();
4165        learn(&mut kept, &[claim(9_600, Some((1, 5)), false)], &[], &heard_up_to(5), 9_000);
4166        assert_eq!(kept.peers["laptop-id"].peer.known_until, 0, "ten minutes ahead: not known");
4167        assert_eq!(sioul_core::health::doubts(9_000, 9_000, None, &[kept.peers["laptop-id"].peer.clone()]).len(), 1);
4168        learn(&mut kept, &[claim(9_630, Some((1, 5)), false)], &[], &heard_up_to(5), 9_600);
4169        assert_eq!(kept.peers["laptop-id"].peer.known_until, 9_600, "half a minute ahead: until now");
4170    }
4171
4172    /// The page's medicines and prescriptions, without their words (those
4173    /// need the translator, which reads the configuration): times as the
4174    /// day's list writes them, what comes for each prescription and when,
4175    /// the medicines it covers when its title does not name them.
4176    #[test]
4177    fn medicines_and_prescriptions_on_the_page() {
4178        let day = |text: &str| text.parse::<Date>().unwrap();
4179        let today = day("2026-10-06");
4180        assert_eq!((hhmm("8:00"), hhmm(" 18:30 "), hhmm("noon"), hhmm("25:00")), ("08:00".into(), "18:30".into(), "noon".into(), "25:00".into()));
4181        assert_eq!(words(&Schedule::Day { times: vec!["20:00".into(), "8:00".into(), "08:00".into()], amounts: Default::default() }), "08:00 · 20:00");
4182        let medicine = |id: &str, prescription: Option<&str>| Medicine {
4183            id: id.into(),
4184            name: id.into(),
4185            dose: String::new(),
4186            schedule: Schedule::Day { times: vec!["07:30".into()], amounts: Default::default() },
4187            prescription: prescription.map(Into::into),
4188            until: None,
4189            paused: false,
4190            generic: String::new(),
4191            strength: String::new(),
4192            since: None,
4193        };
4194        let health = Health {
4195            prescriptions: vec![
4196                // Fetched today, 28 days at a time, valid until February.
4197                Prescription { id: "thyroid".into(), title: "Thyroid".into(), until: Some(day("2027-02-04")), refill_days: Some(28), last_refill: Some(today), ..Default::default() },
4198                // Its title names its medicine; its last valid day passed.
4199                Prescription { id: "levo".into(), title: "Levothyroxine 75 µg".into(), until: Some(day("2026-10-01")), ..Default::default() },
4200                // Last fetched long ago: no pharmacy visit said from three months back.
4201                Prescription { id: "old".into(), title: "Old".into(), refill_days: Some(30), last_refill: Some(day("2026-06-01")), ..Default::default() },
4202            ],
4203            medicines: vec![medicine("Levothyroxine", Some("levo")), medicine("Magnesium", Some("thyroid")), medicine("Iron", Some("thyroid")), medicine("Zinc", None)],
4204            ..Default::default()
4205        };
4206        let errands = health.errands();
4207        // Two days before they run out (6 October + 28 days), then the renewal.
4208        assert_eq!(coming(&errands, &health.prescriptions[0], today), [(Coming::Pharmacy, day("2026-11-01")), (Coming::RenewBy, day("2027-02-04"))]);
4209        assert_eq!(coming(&errands, &health.prescriptions[1], today), [(Coming::ValidUntil, day("2026-10-01"))]);
4210        assert!(coming(&errands, &health.prescriptions[2], today).is_empty());
4211        assert_eq!(covered(&health, &health.prescriptions[0]), ["Magnesium", "Iron"]);
4212        assert!(covered(&health, &health.prescriptions[1]).is_empty() && covered(&health, &health.prescriptions[2]).is_empty());
4213    }
4214
4215    fn take(time: &str, amount: &str) -> Take {
4216        Take { time: time.into(), amount: amount.into() }
4217    }
4218
4219    fn prescription_edit(medicines: serde_json::Value) -> PrescriptionEdit {
4220        serde_json::from_value(serde_json::json!({ "title": "Thyroid and iron", "prescriber": "Dr Martin", "refill_days": 30, "medicines": medicines })).unwrap()
4221    }
4222
4223    /// The prescription's form: its medicines as rows, each with its takes;
4224    /// a row added, changed, taken out; what cannot be saved changes nothing.
4225    #[test]
4226    fn a_prescriptions_medicines_and_their_takes_saved_from_its_form() {
4227        let english = Translator::new("en");
4228        let now: Zoned = "2026-10-08T09:00[Europe/Paris]".parse().unwrap();
4229        let mut health = Health::default();
4230        // Levothyroxine made on its own form first, every morning.
4231        let levo: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Levothyroxine", "dose": "75 µg", "every": "day", "takes": [{ "time": "07:30", "amount": "" }] })).unwrap();
4232        let levo = apply_medicine(&mut health, "", levo, &now, &english).unwrap();
4233        // A new prescription with three medicines: Levothyroxine tied to it, two added there; and a row left empty.
4234        let rows = serde_json::json!([
4235            { "id": levo, "name": "Levothyroxine", "dose": "75 µg", "takes": [{ "time": "07:30", "amount": "" }] },
4236            { "id": "", "name": "Iron", "dose": "1 tablet", "takes": [{ "time": "08:00", "amount": "" }, { "time": "13:00", "amount": "" }, { "time": "20:00", "amount": "2 tablets" }] },
4237            { "id": "", "name": "Magnesium", "dose": "300 mg", "takes": [{ "time": "21:30", "amount": "" }, { "time": "12:30", "amount": "150 mg" }] },
4238            { "id": "", "name": "", "dose": "", "takes": [{ "time": "08:00", "amount": "" }] },
4239        ]);
4240        let id = apply_prescription(&mut health, "", prescription_edit(rows), &english).unwrap();
4241        assert_eq!(health.prescriptions.len(), 1);
4242        assert_eq!(health.medicines.iter().map(|m| (m.name.as_str(), m.prescription.as_deref())).collect::<Vec<_>>(), [("Levothyroxine", Some(id.as_str())), ("Iron", Some(id.as_str())), ("Magnesium", Some(id.as_str()))]);
4243        let iron = health.medicines.iter().find(|m| m.name == "Iron").unwrap().clone();
4244        assert_eq!(iron.takes(), [take("08:00", "1 tablet"), take("13:00", "1 tablet"), take("20:00", "2 tablets")]);
4245        // A medicine like any other: on the page, its prescription form's rows, the doses.
4246        let today = now.date();
4247        assert_eq!(tied(&health, &health.prescriptions[0]).map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Levothyroxine", "Iron", "Magnesium"]);
4248        let row = medicine_row(&iron, today);
4249        assert_eq!((row.when.as_str(), row.amount.as_str(), row.usual.as_str()), ("08:00 · 1 tablet, 13:00 · 1 tablet, 20:00 · 2 tablets", "", "1 tablet"));
4250        assert_eq!(row.takes, [take("08:00", ""), take("13:00", ""), take("20:00", "2 tablets")], "the form says a take's own amount only where it differs");
4251        let levo_row = medicine_row(&health.medicines[0], today);
4252        assert_eq!((levo_row.when.as_str(), levo_row.amount.as_str()), ("07:30", "75 µg"));
4253        let start = now.date().to_zoned(now.time_zone().clone()).unwrap();
4254        let end = start.checked_add(Span::new().days(1)).unwrap();
4255        let doses: Vec<(String, String, String)> = health.doses(&start, &end).into_iter().map(|d| (d.at.strftime("%H:%M").to_string(), d.name, d.dose)).collect();
4256        let expected = [("07:30", "Levothyroxine", "75 µg"), ("08:00", "Iron", "1 tablet"), ("12:30", "Magnesium", "150 mg"), ("13:00", "Iron", "1 tablet"), ("20:00", "Iron", "2 tablets"), ("21:30", "Magnesium", "300 mg")];
4257        assert_eq!(doses, expected.map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())));
4258        // Two takes at one time: said with the medicine's name, and nothing changes.
4259        let before = health.clone();
4260        let twice = serde_json::json!([{ "id": iron.id, "name": "Iron", "dose": "1 tablet", "takes": [{ "time": "08:00", "amount": "" }, { "time": "8:00", "amount": "2 tablets" }] }]);
4261        assert_eq!(apply_prescription(&mut health, &id, prescription_edit(twice), &english), Err("Iron: two takes at 08:00. Keep one, with its amount.".into()));
4262        let unnamed = serde_json::json!([{ "id": "", "name": " ", "dose": "5 mg", "takes": [{ "time": "08:00", "amount": "" }] }]);
4263        assert!(apply_prescription(&mut health, &id, prescription_edit(unnamed), &english).is_err());
4264        let none = serde_json::json!([{ "id": iron.id, "name": "Iron", "dose": "1 tablet", "takes": [] }]);
4265        assert_eq!(apply_prescription(&mut health, &id, prescription_edit(none), &english), Err("Iron: add a take, the time it is taken each day.".into()));
4266        assert_eq!(health, before);
4267        // Iron taken out of the form: gone, with its doses; the others as they were. A form
4268        // without its rows (none given) leaves the medicines alone.
4269        let out = serde_json::json!([{ "id": levo, "name": "Levothyroxine", "dose": "75 µg", "takes": [{ "time": "07:30", "amount": "" }] }, { "id": iron.id, "removed": true }]);
4270        apply_prescription(&mut health, &id, prescription_edit(out), &english).unwrap();
4271        assert_eq!(health.medicines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Levothyroxine", "Magnesium"]);
4272        assert!(health.doses(&start, &end).iter().all(|d| d.name != "Iron"));
4273        let kept = health.clone();
4274        let plain: PrescriptionEdit = serde_json::from_value(serde_json::json!({ "title": "Thyroid and iron", "refill_days": 30 })).unwrap();
4275        apply_prescription(&mut health, &id, plain, &english).unwrap();
4276        assert_eq!(health.medicines, kept.medicines);
4277        // A medicine every few hours, in a row: its name and dose change, its schedule stays.
4278        let hours: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Antibiotic", "dose": "500 mg", "every": "hours", "hours": 8, "from": "2026-10-08T08:00", "prescription": id })).unwrap();
4279        let antibiotic = apply_medicine(&mut health, "", hours, &now, &english).unwrap();
4280        let schedule = health.medicines.iter().find(|m| m.id == antibiotic).unwrap().schedule.clone();
4281        let rename = serde_json::json!([{ "id": antibiotic, "name": "Amoxicillin", "dose": "1 g", "takes": [] }]);
4282        apply_prescription(&mut health, &id, prescription_edit(rename), &english).unwrap();
4283        let changed = health.medicines.iter().find(|m| m.id == antibiotic).unwrap();
4284        assert_eq!((changed.name.as_str(), changed.dose.as_str(), &changed.schedule), ("Amoxicillin", "1 g", &schedule));
4285        // Its own form: takes as rows, a time that does not read said; the times as typed before still read.
4286        let typed: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Zinc", "every": "day", "times": ["12:00", " 18:00"] })).unwrap();
4287        let zinc = apply_medicine(&mut health, "", typed, &now, &english).unwrap();
4288        assert_eq!(health.medicines.iter().find(|m| m.id == zinc).unwrap().takes(), [take("12:00", ""), take("18:00", "")]);
4289        let wrong: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Zinc", "every": "day", "takes": [{ "time": "noon", "amount": "" }] })).unwrap();
4290        assert_eq!(apply_medicine(&mut health, &zinc, wrong, &now, &english), Err("“noon” is not a time of day.".into()));
4291    }
4292
4293    /// A take's own amount wherever a dose is said: the Porch, the home
4294    /// screen's card, the reminder (desktop and phone: `named`), the day's list.
4295    #[test]
4296    fn a_takes_own_amount_is_said_wherever_its_dose_is() {
4297        let dir = std::env::temp_dir().join(format!("sioul-takes-{}", std::process::id()));
4298        let _ = std::fs::remove_dir_all(&dir);
4299        std::fs::create_dir_all(&dir).unwrap();
4300        let path = dir.join("health.toml");
4301        let mut iron = Medicine { id: "iron".into(), name: "Iron".into(), dose: String::new(), schedule: Schedule::Day { times: Vec::new(), amounts: Default::default() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None };
4302        iron.set_takes("1 tablet", &[take("08:00", ""), take("20:00", "2 tablets")]).unwrap();
4303        Health { medicines: vec![iron], ..Health::default() }.save(&path).unwrap();
4304        let health = Health::load(&path);
4305        let english = Translator::new("en");
4306        let at = |text: &str| text.parse::<Zoned>().unwrap();
4307        let alone = Knowledge { record_lost: None, peers: Vec::new() };
4308        let state = HealthState::default();
4309        // The Porch at 20:01: the evening's take, 2 tablets.
4310        let evening = at("2026-10-08T20:01[Europe/Paris]");
4311        let porch = serde_json::to_value(porch_doses(&health, &state, &DoseRecords::default(), &DoseReminders::default(), "desk-id", &alone, &evening, false, true, &english)).unwrap();
4312        let due: Vec<(&str, &str)> = porch["due"].as_array().unwrap().iter().map(|d| (d["time"].as_str().unwrap(), d["dose"].as_str().unwrap())).collect();
4313        assert_eq!(due, [("20:00", "2 tablets")]);
4314        // The home screen's card at 07:00: both, each with its amount.
4315        let card = doses_for_card(&health, &state, &DoseRecords::default(), true, &alone, &at("2026-10-08T07:00[Europe/Paris]"));
4316        assert_eq!(card.iter().map(|d| (d.time.as_str(), d.name.as_str())).collect::<Vec<_>>(), [("08:00", "Iron · 1 tablet"), ("20:00", "Iron · 2 tablets")]);
4317        // The reminder's words, the phone's alarms' and the day's list: each dose its take's amount.
4318        let doses = health.doses(&at("2026-10-08T00:00[Europe/Paris]"), &at("2026-10-09T00:00[Europe/Paris]"));
4319        assert_eq!(doses.iter().map(named).collect::<Vec<_>>(), ["Iron · 1 tablet", "Iron · 2 tablets"]);
4320        // The late dose's question: its take's amount.
4321        assert_eq!(health.amount_of(&doses[1].key, evening.time_zone()).as_deref(), Some("2 tablets"));
4322        let _ = std::fs::remove_dir_all(&dir);
4323    }
4324
4325    /// A medicine's generic name and strength, saved from both forms and read
4326    /// back; said precisely on the page; and the view a doctor or a pharmacist
4327    /// reads: the generic name and strength first, then the brand, the takes,
4328    /// how long, who prescribed it; a prescription's, or all those taken now.
4329    #[test]
4330    fn a_doctor_or_pharmacist_reads_the_generic_name_first() {
4331        let english = Translator::new("en");
4332        let now: Zoned = "2026-10-08T09:00[Europe/Paris]".parse().unwrap();
4333        let today = now.date();
4334        let mut health = Health::default();
4335        let rows = serde_json::json!([
4336            { "id": "", "name": "Thyrolan", "generic": "levothyroxine", "strength": "75 µg", "dose": "1 tablet", "takes": [{ "time": "07:30", "amount": "" }] },
4337            { "id": "", "name": " ", "generic": "metformin", "strength": "500 mg per tablet", "dose": "1 tablet", "takes": [{ "time": "08:00", "amount": "" }, { "time": "20:00", "amount": "2 tablets" }] },
4338        ]);
4339        let edit: PrescriptionEdit = serde_json::from_value(serde_json::json!({ "title": "Thyroid and sugar", "prescriber": "Dr Martin", "until": "2027-02-04", "medicines": rows })).unwrap();
4340        let id = apply_prescription(&mut health, "", edit, &english).unwrap();
4341        // No name given: the generic name is its name, for reminders and an older Sioul.
4342        let fields: Vec<(&str, &str, &str)> = health.medicines.iter().map(|m| (m.name.as_str(), m.generic.as_str(), m.strength.as_str())).collect();
4343        assert_eq!(fields, [("Thyrolan", "levothyroxine", "75 µg"), ("metformin", "metformin", "500 mg per tablet")]);
4344        // Its own form: since a week, every eight hours.
4345        let antibiotic: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Amoxi", "generic": "amoxicillin", "strength": "500 mg", "dose": "1 capsule", "every": "hours", "hours": 8, "from": "2026-10-08T08:00", "since": "2026-10-01" })).unwrap();
4346        apply_medicine(&mut health, "", antibiotic, &now, &english).unwrap();
4347        // Paused, and past its last day: not taken now.
4348        let mut paused = health.medicines[0].clone();
4349        (paused.id, paused.name, paused.paused, paused.prescription) = ("zinc".into(), "Zinc".into(), true, None);
4350        let mut over = health.medicines[0].clone();
4351        (over.id, over.name, over.prescription, over.until) = ("iron".into(), "Iron".into(), None, Some("2026-10-01".parse().unwrap()));
4352        health.medicines.extend([paused, over]);
4353        // Written and read back, as they were.
4354        let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
4355        assert_eq!(again, health);
4356        // Nothing given, nothing to call it: said.
4357        let nameless: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "", "generic": " ", "every": "day", "takes": [{ "time": "08:00" }] })).unwrap();
4358        assert_eq!(apply_medicine(&mut health.clone(), "", nameless, &now, &english), Err("A name is needed.".into()));
4359        // On the page, precisely; the reminders keep the short name.
4360        let levo = medicine_row(&health.medicines[0], today);
4361        assert_eq!((levo.precise.as_str(), levo.line.as_str()), ("Thyrolan — levothyroxine 75 µg", "Thyrolan — levothyroxine 75 µg · 07:30 · 1 tablet"));
4362        assert_eq!(medicine_row(&health.medicines[1], today).precise, "metformin 500 mg per tablet");
4363        let start = now.date().to_zoned(now.time_zone().clone()).unwrap();
4364        let end = start.checked_add(Span::new().days(1)).unwrap();
4365        assert_eq!(health.doses(&start, &end).iter().find(|d| d.at.hour() == 7).map(|d| d.name.as_str()), Some("Thyrolan"));
4366        // The prescription's view: who and until when above, the generic names first.
4367        let view = for_professional_of(&health, &id, today, &english);
4368        assert_eq!(view.title, "Thyroid and sugar");
4369        assert_eq!(view.about, ["Prescribed by Dr Martin", "Valid until Thursday 4 February 2027"]);
4370        let levo = &view.medicines[0];
4371        assert_eq!((levo.molecule.as_str(), levo.brand.as_str(), levo.takes.clone(), levo.prescriber.as_str()), ("levothyroxine 75 µg", "Thyrolan", vec!["07:30 — 1 tablet".to_string()], ""));
4372        let metformin = &view.medicines[1];
4373        assert_eq!((metformin.molecule.as_str(), metformin.brand.as_str()), ("metformin 500 mg per tablet", ""), "its name is its generic name: said once");
4374        assert_eq!(metformin.takes, ["08:00 — 1 tablet", "20:00 — 2 tablets"]);
4375        assert_eq!(view.medicines.len(), 2, "the antibiotic is not on it");
4376        // All those taken now: neither the paused one nor the one past its last day.
4377        let all = for_professional_of(&health, "", today, &english);
4378        assert_eq!((all.title.as_str(), all.about.clone()), ("Current medicines", vec!["As of Thursday 8 October 2026".to_string()]));
4379        assert_eq!(all.medicines.iter().map(|m| m.molecule.as_str()).collect::<Vec<_>>(), ["levothyroxine 75 µg", "metformin 500 mg per tablet", "amoxicillin 500 mg"]);
4380        assert_eq!(all.medicines[0].prescriber, "Prescribed by Dr Martin");
4381        let amoxicillin = &all.medicines[2];
4382        assert_eq!((amoxicillin.takes.clone(), amoxicillin.since.as_str(), amoxicillin.brand.as_str()), (vec!["every 8 hours — 1 capsule".to_string()], "Taken since Thursday 1 October, 7 days", "Amoxi"));
4383        // A paused medicine in its prescription's view: said.
4384        health.medicines[1].paused = true;
4385        assert_eq!(for_professional_of(&health, &id, today, &english).medicines[1].notes, ["Paused for now"]);
4386        // In French, the generic names as typed.
4387        let french = for_professional_of(&health, "", today, &Translator::new("fr"));
4388        assert_eq!(french.title, "Médicaments en cours");
4389        assert_eq!(french.medicines[1].since, "Pris depuis le jeudi 1er octobre, 7 jours");
4390        assert_eq!(french.medicines[0].molecule, "levothyroxine 75 µg");
4391        // Nothing to show: said.
4392        assert_eq!(for_professional_of(&Health::default(), "", today, &english).empty, "No medicine to show.");
4393        assert_eq!((how_long("2024-01-01".parse().unwrap(), today, &english), how_long("2026-06-01".parse().unwrap(), today, &english), how_long(today, today, &english)), ("2 years".into(), "4 months".into(), String::new()));
4394    }
4395
4396    /// The Porch's doses at a desktop, on a health file written for the test
4397    /// (never yours) and fake times: due now while Sioul runs; due earlier
4398    /// today, reminded by its notification but not answered; due while Sioul
4399    /// ran nowhere; another device not heard lately; asleep with doses silent;
4400    /// marked; the day over.
4401    #[test]
4402    fn the_porch_shows_todays_doses_until_marked() {
4403        let dir = std::env::temp_dir().join(format!("sioul-porch-doses-{}", std::process::id()));
4404        let _ = std::fs::remove_dir_all(&dir);
4405        std::fs::create_dir_all(&dir).unwrap();
4406        let path = dir.join("health.toml");
4407        let file = "[[medicine]]\nid = \"levo\"\nname = \"Levothyroxine\"\ndose = \"75 µg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"07:30\"]\n\n\
4408                    [[medicine]]\nid = \"iron\"\nname = \"Iron\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"12:00\", \"21:00\"]\n\n\
4409                    [[medicine]]\nid = \"zinc\"\nname = \"Zinc\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"09:00\"]\n";
4410        std::fs::write(&path, file).unwrap();
4411        let health = Health::load(&path);
4412        assert_eq!(health.medicines.len(), 3);
4413        let english = Translator::new("en");
4414        let at = |text: &str| text.parse::<Zoned>().unwrap();
4415        let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
4416        let alone = Knowledge { record_lost: None, peers: Vec::new() };
4417        let shown = |state: &HealthState, knowledge: &Knowledge, now: &Zoned, silent: bool, heard: bool| serde_json::to_value(porch_doses(&health, state, &DoseRecords::default(), &DoseReminders::default(), "desk-id", knowledge, now, silent, heard, &english)).unwrap();
4418        let mut state = HealthState::default();
4419        // 07:30, reminded by its notification while Sioul ran, never answered.
4420        state.reminded.insert(key("levo", "2026-10-06T07:30[Europe/Paris]"), at("2026-10-06T07:31[Europe/Paris]").timestamp().as_second());
4421        let noon = at("2026-10-06T12:00:40[Europe/Paris]");
4422        let view = shown(&state, &alone, &noon, false, true);
4423        let due = view["due"].as_array().unwrap();
4424        assert_eq!(due.len(), 2, "{view}");
4425        // Due earlier, reminded, not answered: on the Porch, "Taken…" asking when (late).
4426        assert_eq!((due[0]["time"].as_str(), due[0]["name"].as_str(), due[0]["dose"].as_str(), due[0]["late"].as_bool()), (Some("07:30"), Some("Levothyroxine"), Some("75 µg"), Some(true)));
4427        // Due now, Sioul running, its notification given or not yet: "Taken", marked now; nothing in doubt.
4428        assert_eq!((due[1]["time"].as_str(), due[1]["late"].as_bool(), due[1]["doubt"].as_str()), (Some("12:00"), Some(false), Some("")));
4429        // Due while Sioul ran nowhere (09:00, reminded nowhere): the question, apart.
4430        let closed = view["closed"].as_array().unwrap();
4431        assert_eq!(closed.len(), 1);
4432        assert_eq!((closed[0]["key"].as_str(), closed[0]["time"].as_str()), (Some(key("zinc", "2026-10-06T09:00[Europe/Paris]").as_str()), Some("09:00")));
4433        // Before your other devices were heard from, the question waits; today's doses do not.
4434        let early = shown(&state, &alone, &noon, false, false);
4435        assert_eq!((early["due"].as_array().unwrap().len(), early["closed"].as_array().unwrap().len()), (2, 0));
4436        // Asleep, doses staying silent: nothing until waking.
4437        let asleep = shown(&state, &alone, &noon, true, true);
4438        assert!(asleep["due"].as_array().unwrap().is_empty() && asleep["closed"].as_array().unwrap().is_empty());
4439        // The laptop heard in full at 07:35, not since: it may have marked them. Check first, never "not taken".
4440        let heard = at("2026-10-06T07:35[Europe/Paris]").timestamp().as_second();
4441        let unsure = Knowledge { record_lost: None, peers: vec![Peer { name: "laptop".into(), known_until: heard, closed: false, delay: Some(30), broken: None, heard, ..Peer::default() }] };
4442        let doubted = shown(&state, &unsure, &noon, false, true);
4443        for row in doubted["due"].as_array().unwrap().iter().chain(doubted["closed"].as_array().unwrap()) {
4444            let doubt = row["doubt"].as_str().unwrap();
4445            assert!(doubt.starts_with("Sioul can't tell whether it was taken") && doubt.contains("laptop") && doubt.ends_with("Check before taking it."), "{doubt}");
4446            assert!(!doubt.to_lowercase().contains("not taken"), "{doubt}");
4447        }
4448        // Marked, taken or said not taken: gone.
4449        state.taken.insert(key("iron", "2026-10-06T12:00[Europe/Paris]"), noon.timestamp().as_second());
4450        state.not_taken.insert(key("levo", "2026-10-06T07:30[Europe/Paris]"), noon.timestamp().as_second());
4451        assert!(shown(&state, &alone, &noon, false, true)["due"].as_array().unwrap().is_empty());
4452        // The day over: today's are gone at midnight; the evening's iron, reminded nowhere, is the question's.
4453        let night = shown(&state, &alone, &at("2026-10-07T00:30[Europe/Paris]"), false, true);
4454        assert!(night["due"].as_array().unwrap().is_empty());
4455        assert_eq!(night["closed"][0]["time"].as_str(), Some("Tue 21:00"));
4456        let _ = std::fs::remove_dir_all(&dir);
4457    }
4458
4459    /// A dose whose reminder could not be shown (no notification server) while
4460    /// Sioul ran, on a health file written for the test: recorded apart from a
4461    /// reminder (`note_minute`), still asked about, but under its own question,
4462    /// never "Due while Sioul was closed"; a dose due while Sioul ran nowhere
4463    /// keeps that one. Both say the doubt, never "not taken"; the
4464    /// notification's title says which, in English and French.
4465    #[test]
4466    fn a_reminder_not_shown_is_asked_about_under_its_own_question() {
4467        let dir = std::env::temp_dir().join(format!("sioul-unshown-doses-{}", std::process::id()));
4468        let _ = std::fs::remove_dir_all(&dir);
4469        std::fs::create_dir_all(&dir).unwrap();
4470        let path = dir.join("health.toml");
4471        let file = "[[medicine]]\nid = \"demo-a\"\nname = \"Demo A\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\"]\n\n\
4472                    [[medicine]]\nid = \"demo-b\"\nname = \"Demo B\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"07:00\"]\n";
4473        std::fs::write(&path, file).unwrap();
4474        let health = Health::load(&path);
4475        let (english, french) = (Translator::new("en"), Translator::new("fr"));
4476        let at = |text: &str| text.parse::<Zoned>().unwrap();
4477        let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
4478        let (a, b) = (key("demo-a", "2026-10-06T08:00[Europe/Paris]"), key("demo-b", "2026-10-06T07:00[Europe/Paris]"));
4479        let alone = Knowledge { record_lost: None, peers: Vec::new() };
4480        let noon = at("2026-10-06T12:00[Europe/Paris]");
4481        // The minute at 08:00: Demo A's notification failed. Not reminded: noted apart.
4482        let mut state = HealthState::default();
4483        note_minute(&mut state, &[], std::slice::from_ref(&a), &[], &[], at("2026-10-06T08:00:30[Europe/Paris]").timestamp().as_second());
4484        assert!(state.reminded.is_empty() && state.reminder_unshown(&a));
4485        // Demo B, at 07:00, due while Sioul ran nowhere: the usual question, first.
4486        let view = serde_json::to_value(porch_doses(&health, &state, &DoseRecords::default(), &DoseReminders::default(), "desk-id", &alone, &noon, false, true, &english)).unwrap();
4487        let closed = view["closed"].as_array().unwrap();
4488        assert_eq!(closed.len(), 2, "{view}");
4489        assert_eq!((closed[0]["key"].as_str(), closed[0]["question"].as_str()), (Some(b.as_str()), Some("Due while Sioul was closed, and marked nowhere Sioul can see: did you take them?")));
4490        // Demo A, Sioul running: still asked about, under what happened.
4491        assert_eq!(closed[1]["key"].as_str(), Some(a.as_str()));
4492        let question = closed[1]["question"].as_str().unwrap();
4493        assert!(question.starts_with("Due while Sioul ran, but their reminder could not be shown") && !question.contains("closed"), "{question}");
4494        assert!(view["due"].as_array().unwrap().iter().all(|row| row["question"] == ""), "today's doses carry no question");
4495        // The Health page's list says the same.
4496        let rows = missed_rows(&health, &state, &DoseReminders::default(), &alone, &noon, &french);
4497        assert_eq!(rows.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
4498        assert!(rows[1].question.starts_with("Prévus pendant que Sioul tournait, mais leur rappel n’a pas pu s’afficher"), "{}", rows[1].question);
4499        // Another device not heard since: the doubt said under both, never "not taken".
4500        let heard = at("2026-10-06T06:30[Europe/Paris]").timestamp().as_second();
4501        let unsure = Knowledge { record_lost: None, peers: vec![Peer { name: "laptop".into(), known_until: heard, closed: false, delay: Some(30), broken: None, heard, ..Peer::default() }] };
4502        for row in missed_rows(&health, &state, &DoseReminders::default(), &unsure, &noon, &english) {
4503            assert!(row.doubt.starts_with("Sioul can't tell whether it was taken") && !row.doubt.to_lowercase().contains("not taken"), "{}", row.doubt);
4504        }
4505        // The notification's title: one kind, the other, both.
4506        let missed = state.unanswered(&health, &noon, MISSED_HOURS, GRACE_MINUTES);
4507        assert_eq!(missed_title(&missed, &state, &DoseReminders::default(), &english), "Doses due earlier");
4508        assert_eq!(missed_title(&missed[1..], &state, &DoseReminders::default(), &english), "A reminder could not be shown");
4509        assert_eq!(missed_title(&missed[..1], &state, &DoseReminders::default(), &english), "While Sioul was closed");
4510        assert_eq!(missed_title(&missed[1..], &state, &DoseReminders::default(), &french), "Un rappel n’a pas pu s’afficher");
4511        // Reminded later (another device, or notifications back): no question at all.
4512        note_minute(&mut state, std::slice::from_ref(&a), &[], &[], &[], at("2026-10-06T08:05[Europe/Paris]").timestamp().as_second());
4513        let rows = missed_rows(&health, &state, &DoseReminders::default(), &alone, &noon, &english);
4514        assert_eq!(rows.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), [b.as_str()]);
4515        let _ = std::fs::remove_dir_all(&dir);
4516    }
4517
4518    /// A dose's reminder is recorded as what became of it (review of battery
4519    /// part C, the third and fourth reviews' H1): the tick waits for each
4520    /// notice's first try (`remind_dose`, `Sending::outcome`, `FIRST_TRY` at
4521    /// most in all, the record let go meanwhile); shown, a reminder; surely
4522    /// not shown, `unshown`; sent and not known shown, `maybe` (with `unshown`
4523    /// beside it, for an older Sioul). Shown late, it becomes a reminder then,
4524    /// only while nothing was said of it meanwhile: marked here, answered on
4525    /// another device, or reminded by one (the phone's alarm) leaves it as it
4526    /// is, never contradicted, never downgraded; a try after the first sent
4527    /// and not answered in time is recorded maybe.
4528    #[test]
4529    fn a_dose_reminder_counts_once_shown_and_late_only_if_still_to_remind() {
4530        let key = "mag@1791360000".to_string();
4531        let (state, none) = (HealthState::default(), DoseRecords::default());
4532        assert!(late_reminder_wanted(&state, &none, &key), "nothing said of it: shown late");
4533        let mut taken = HealthState::default();
4534        taken.taken.insert(key.clone(), 1_791_360_100);
4535        assert!(!late_reminder_wanted(&taken, &none, &key), "taken meanwhile");
4536        let mut skipped = HealthState::default();
4537        skipped.not_taken.insert(key.clone(), 1_791_360_100);
4538        assert!(!late_reminder_wanted(&skipped, &none, &key), "said not taken");
4539        let mut phone = HealthState::default();
4540        phone.unshown.insert(key.clone(), 1_791_360_005);
4541        phone.reminded.insert(key.clone(), 1_791_360_600);
4542        assert!(!late_reminder_wanted(&phone, &none, &key), "the phone's alarm reminded it meanwhile: its turn kept");
4543        let mut records = DoseRecords::default();
4544        records.answer(&key, "phone", Answer { state: sioul_core::doses::TAKEN.into(), at: 1_791_360_300, noted: 1_791_360_300, name: "phone".into(), kind: "phone".into() });
4545        assert!(!late_reminder_wanted(&state, &records, &key), "answered on the phone, its mark not here yet");
4546        // Shown late: a reminder then, its first try's record kept; nothing over an earlier word.
4547        let mut first = HealthState::default();
4548        first.unshown.insert(key.clone(), 1_791_360_005);
4549        assert!(first.reminder_unshown(&key));
4550        late_reminded(&mut first, &key, 1_791_360_040);
4551        assert_eq!(first.reminded.get(&key), Some(&1_791_360_040));
4552        assert!(!first.reminder_unshown(&key), "a reminder now: not asked as one that could not be shown");
4553        // Recorded, it is no longer to remind: a late show reads its words
4554        // before it records (`notify::shown_late`), or it would take itself away.
4555        assert!(!late_reminder_wanted(&first, &none, &key), "recorded: no longer to remind");
4556        let mut earlier = phone.clone();
4557        late_reminded(&mut earlier, &key, 1_791_360_700);
4558        assert_eq!(earlier.reminded.get(&key), Some(&1_791_360_600), "the phone's reminder kept");
4559        let mut marked = taken.clone();
4560        late_reminded(&mut marked, &key, 1_791_360_700);
4561        assert!(marked.reminded.is_empty(), "nothing recorded over a mark");
4562        // The minute's record: a reminder sent and not known shown is written
4563        // `unshown` for an older Sioul (what each device did is in its own file);
4564        // answered late, a reminder.
4565        let mut minute = HealthState::default();
4566        note_minute(&mut minute, &[], &[], std::slice::from_ref(&key), &[], 1_791_360_002);
4567        assert!(minute.unshown.contains_key(&key) && minute.reminder_unshown(&key) && !minute.reminded.contains_key(&key));
4568        late_reminded(&mut minute, &key, 1_791_360_004);
4569        assert!(!minute.reminder_unshown(&key), "answered late: shown, upgraded");
4570        // The tick, read from the source: each first try's outcome waited for, the record let go, recorded as it went.
4571        let source = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/health.rs")).unwrap();
4572        let tick = source.split("pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {").nth(1).and_then(|rest| rest.split("\n}\n").next()).unwrap();
4573        assert!(tick.contains("sioul_sync::notify::remind_dose(") && !tick.contains("sioul_sync::notify::remind(&title, &body"), "a dose's reminder says what became of it");
4574        let waited = tick.split("drop(_held);").nth(1).unwrap();
4575        assert!(waited.contains("sent.outcome(deadline.saturating_duration_since("));
4576        for (outcome, list) in [("Outcome::Shown => reminded.push(key)", ""), ("Outcome::Unshown(e) => {\n                unshown.push(key);", ""), ("Outcome::Maybe(e) => {\n                maybe.push(key);", "")] {
4577            assert!(waited.contains(outcome), "{outcome}{list}");
4578        }
4579        assert!(waited.find("let _held = state_held();").unwrap() < waited.find("note_minute(record, &reminded, &unshown, &maybe").unwrap(), "recorded under the record's lock again");
4580    }
4581
4582    /// The phone's alarm, at a dose's time plus `WAIT_FOR_NEWS` (the desk kept
4583    /// the doses), as it holds what the others did (review of battery part C,
4584    /// the fifth review: THE RULE): plain only on the desk's own word that it
4585    /// did not remind the dose (its first try not gone out, then its promise
4586    /// of quiet past the alarm; or closed, everything read); every other way,
4587    /// the doubt first, each device said; another phone always a doubt (its
4588    /// own alarm). In English and French, the French reasons joined with a
4589    /// thin space before ";".
4590    #[test]
4591    fn a_phone_reminds_plainly_only_on_the_others_word() {
4592        use sioul_core::dose_reminders::{QUIET_AFTER, others};
4593        use sioul_core::health::Said;
4594        let due: i64 = 1_791_360_000;
4595        let key = format!("levo@{due}");
4596        let at_alarm = due + WAIT_FOR_NEWS;
4597        let (english, french) = (Translator::new("en"), Translator::new("fr"));
4598        let desk = |exported: i64, working: bool| Peer {
4599            name: "desk".into(),
4600            id: "desk-id".into(),
4601            said: Some(Said { started: due - 10_800, closed: if working { 0 } else { exported }, working, exported, imported: exported, doses: true, left: false, phone: false }),
4602            complete: true,
4603            seen: exported + 5,
4604            heard: exported,
4605            ..Peer::default()
4606        };
4607        let in_use = [desk(at_alarm - 30, true)];
4608        let with = |tried: Tried| {
4609            let mut told = DoseReminders::default();
4610            *told.entry(&key, "desk-id") = tried;
4611            told
4612        };
4613        let said_as = |told: &DoseReminders, peers: &[Peer], words: &Translator| match others(&key, due, at_alarm, "phone-id", true, Some(told), peers, true) {
4614            Others::Doubt(e) => Some(elsewhere_said(&e, due, words)),
4615            Others::First => None,
4616            Others::Reminded => Some("reminded".into()),
4617        };
4618        // H1: the desk's notice went and was not known shown: "may have shown".
4619        let sent = with(Tried { maybe: due + 2, ..Tried::default() });
4620        let when = |at: i64, words: &Translator| when_said(&Timestamp::from_second(at).unwrap().to_zoned(TimeZone::system()), words);
4621        assert_eq!(said_as(&sent, &in_use, &english), Some(format!("desk may have shown its reminder {}", when(due + 2, &english))));
4622        assert_eq!(said_as(&sent, &in_use, &french), Some(format!("desk a peut-être affiché son rappel {}", when(due + 2, &french))));
4623        // Shown (answered late): reminded, the alarm silent.
4624        assert_eq!(said_as(&with(Tried { maybe: due + 2, shown: due + 3, ..Tried::default() }), &in_use, &english).as_deref(), Some("reminded"));
4625        // H2: not gone out, its tries going on (no promise yet): "could not show its reminder …, and may have shown it since".
4626        let tried = with(Tried { unshown: due + 5, ..Tried::default() });
4627        assert!(said_as(&tried, &in_use, &english).is_some_and(|w| w.contains("could not show its reminder") && w.contains("may have shown it since")));
4628        // Not gone out, then quiet past the alarm, promised: the alarm is the first, plainly.
4629        let promised = with(Tried { unshown: due + 5, quiet: due + QUIET_AFTER, ..Tried::default() });
4630        assert_eq!(said_as(&promised, &in_use, &english), None);
4631        // In use, no word of its own on the dose read here: "was in use and may have reminded you".
4632        assert!(said_as(&DoseReminders::default(), &in_use, &english).is_some_and(|w| w.starts_with("desk was in use and may have reminded you")));
4633        // Closed before the dose, everything read: did not; plainly.
4634        assert_eq!(said_as(&DoseReminders::default(), &[Peer { acknowledged: due - 10_800, ..desk(due - 600, false) }], &english), None, "its opening, the one this phone acknowledged");
4635        // C1: two phones; the other one's own alarm may ring: a doubt, named, whatever the desk promised.
4636        let tablet = Peer { name: "tablet".into(), id: "tablet-id".into(), said: Some(Said { phone: true, ..desk(due - 600, false).said.unwrap() }), ..desk(due - 600, false) };
4637        assert_eq!(said_as(&promised, &[in_use[0].clone(), tablet.clone()], &english).as_deref(), Some("tablet may have reminded you too, by its own alarm"));
4638        assert_eq!(said_as(&promised, &[in_use[0].clone(), tablet], &french).as_deref(), Some("tablet vous l’a peut-être rappelée aussi, par sa propre alarme"));
4639        // Two reasons, joined: in French with a thin space before ";".
4640        let two = elsewhere_said(&[Elsewhere::Unknown { device: Named { id: "a".into(), name: "desk".into(), phone: false } }, Elsewhere::Alarm { device: Named { id: "b".into(), name: "tablet".into(), phone: false } }], due, &french);
4641        assert_eq!(two, format!("desk était utilisé et vous l’a peut-être rappelée {}\u{202f}; tablet vous l’a peut-être rappelée aussi, par sa propre alarme", when(due, &french)));
4642        assert_eq!(joined(vec!["a".into(), "b".into()], &english), "a; b");
4643        assert_eq!(joined(vec!["a".into(), "b".into()], &french), "a\u{202f}; b");
4644        // The file not read here: nothing known.
4645        assert!(matches!(others(&key, due, at_alarm, "phone-id", true, None, &in_use, true), Others::Doubt(ref e) if e == &[Elsewhere::Unread]));
4646    }
4647
4648    /// What the desk does for a dose at its tick (`plan`; the fifth review's
4649    /// C1, M1, M2): its first reminder only where it kept the doses at the
4650    /// dose's time, within its window; with a phone sharing the doses
4651    /// (one or two: C1), none in the quiet around the phone's alarm, promised;
4652    /// after it, its tries go on, the doubt first, whether the phone rang or
4653    /// not (M1), until the grace's end; at waking, counted from waking (M2).
4654    #[test]
4655    fn a_computer_reminds_first_only_when_it_is_and_keeps_its_word() {
4656        use sioul_core::dose_reminders::{NEVER, QUIET_AFTER};
4657        use sioul_sync::notify::Late;
4658        let since: i64 = 1_791_360_000;
4659        let unshown = Some(Tried { unshown: since + 5, ..Tried::default() });
4660        // Kept the doses then, nothing recorded: its first, within its window.
4661        assert_eq!(plan(None, true, true, since, since + 30, true), Plan::First);
4662        assert_eq!(plan(None, true, true, since, since + LATE_UNTIL - 1, true), Plan::First);
4663        assert_eq!(plan(None, true, true, since, since + WAIT_FOR_NEWS - 1, false), Plan::First, "no phone: its window is the wait for news");
4664        // Past its window with a phone: quiet until after its alarm, promised.
4665        assert_eq!(plan(None, true, true, since, since + LATE_UNTIL, true), Plan::Quiet(since + QUIET_AFTER));
4666        // Kept only since after the dose's time (a restart, the lease moved): never its first.
4667        assert_eq!(plan(None, true, false, since, since + 60, true), Plan::Quiet(since + QUIET_AFTER));
4668        assert_eq!(plan(None, true, false, since, since + 60, false), Plan::Late(None));
4669        // Its first try not gone out: tries, the doubt first; quiet in the phone's window, promised;
4670        // after it, the tries again, the doubt first, whether the phone rang or not (M1).
4671        assert_eq!(plan(unshown, true, true, since, since + 120, true), Plan::Late(Some(Late::Retry)));
4672        assert_eq!(plan(unshown, true, true, since, since + LATE_UNTIL + 10, true), Plan::Quiet(since + QUIET_AFTER));
4673        let paused = Some(Tried { unshown: since + 5, quiet: since + QUIET_AFTER, ..Tried::default() });
4674        assert_eq!(plan(paused, true, true, since, since + QUIET_AFTER - 1, true), Plan::Not, "its promise kept");
4675        assert_eq!(plan(paused, true, true, since, since + QUIET_AFTER + 60, true), Plan::Late(Some(Late::Retry)));
4676        // Two phones count as phones (C1): the quiet holds as for one.
4677        assert_eq!(plan(unshown, true, true, since, since + 8 * 60, true), Plan::Quiet(since + QUIET_AFTER));
4678        // An earlier Sioul here sent it, not known shown: unconfirmed, the doubt first; shown, or never again: nothing.
4679        assert_eq!(plan(Some(Tried { maybe: since + 2, ..Tried::default() }), true, true, since, since + 120, true), Plan::Late(Some(Late::Unconfirmed)));
4680        assert_eq!(plan(Some(Tried { maybe: since + 2, shown: since + 3, ..Tried::default() }), true, true, since, since + 120, true), Plan::Not);
4681        assert_eq!(plan(Some(Tried { unshown: since + 5, quiet: NEVER, ..Tried::default() }), true, true, since, since + 1_200, true), Plan::Not);
4682        // The file not read: a first try only.
4683        assert_eq!(plan(None, false, true, since, since + 30, true), Plan::First);
4684        assert_eq!(plan(None, false, false, since, since + 120, true), Plan::Quiet(since + QUIET_AFTER), "the file not read, not its first: quiet around the phone's alarm");
4685        assert_eq!(plan(None, false, true, since, since + QUIET_AFTER + 60, true), Plan::Late(None), "then the doubt first, its late notice going all the same (L1)");
4686        // The tries' words: the doubt first, why it is late first (L6: a notice on the screen since its time, answered late, "was not confirmed in time").
4687        let dose = sioul_core::health::Dose { key: format!("levo@{since}"), medicine: "levo".into(), name: "Levothyroxine".into(), dose: "75 µg".into(), at: Timestamp::from_second(since).unwrap().to_zoned(TimeZone::system()) };
4688        let alone = Knowledge { record_lost: None, peers: Vec::new() };
4689        let (title, retry) = late_said(&dose, &alone, since + 120, since, Some(Late::Retry));
4690        assert_eq!(title, say("dose-check-title", &[("dose", "Levothyroxine · 75 µg".to_string())]));
4691        assert!(retry.contains(&say("dose-doubt-late", &[("when", moment_said(since))])), "{retry}");
4692        let (_, unconfirmed) = late_said(&dose, &alone, since + 120, since, Some(Late::Unconfirmed));
4693        assert!(unconfirmed.contains(&say("dose-doubt-unconfirmed", &[("when", moment_said(since))])) && !unconfirmed.contains(&say("dose-doubt-late", &[("when", moment_said(since))])), "{unconfirmed}");
4694        let (_, other) = late_said(&dose, &alone, since + 120, since, None);
4695        assert!(other.contains(&say("dose-doubt-other", &[("when", moment_said(since))])), "{other}");
4696        // In English, as L6 asks.
4697        assert_eq!(said(&Translator::new("en"), "dose-doubt-unconfirmed", &[("when", "at 08:00".to_string())]), "its reminder, due at 08:00, was not confirmed in time, and it may have been taken since without being marked");
4698    }
4699
4700    /// The sixth review's timelines R1 to R9, each device's decision as the
4701    /// tick (`first_here`, `plan`) and the phone's alarm (`dose_reminders::others`)
4702    /// compose it from what that device has read then: never two plain
4703    /// reminders of one dose. The handshake: the desk is the first only once
4704    /// every phone said, before the dose's time, that it read its current
4705    /// opening; else quiet around the phone's alarm, then the doubt first,
4706    /// the phone named. The phone takes the desk's "closed" as its word only
4707    /// after its own pull went through since the dose, and nothing later read
4708    /// of the desk.
4709    #[test]
4710    fn a_desk_opened_just_before_a_dose_is_never_a_second_first() {
4711        use sioul_core::dose_reminders::{QUIET_AFTER, others};
4712        use sioul_core::health::Said;
4713        let due: i64 = 1_791_360_000; // 08:00
4714        let key = format!("levo@{due}");
4715        let english = Translator::new("en");
4716        // The desk as the phone last read it: closed at 23:00, everything read.
4717        let closed_desk = |claimed: i64| Peer {
4718            name: "desk".into(),
4719            id: "desk-id".into(),
4720            said: Some(Said { started: due - 36_000, closed: due - 32_400, working: false, exported: due - 32_400, imported: due - 32_400, doses: true, left: false, phone: false }),
4721            complete: true,
4722            seen: due - 32_400,
4723            heard: due - 32_400,
4724            claimed,
4725            // This phone's own word on that opening (its sealed entry): its "closed" is its word only for it, or later.
4726            acknowledged: due - 36_000,
4727            ..Peer::default()
4728        };
4729        let phone = Peer {
4730            name: "phone".into(),
4731            id: "phone-id".into(),
4732            said: Some(Said { started: due - 7_200, closed: due - 3_600, working: false, exported: due - 3_600, imported: due - 3_600, doses: true, left: false, phone: true }),
4733            complete: true,
4734            seen: due - 3_600,
4735            heard: due - 3_600,
4736            ..Peer::default()
4737        };
4738        let desk_knows = Knowledge { record_lost: None, peers: vec![phone.clone()] };
4739        let nothing = DoseReminders::default();
4740        let opened = due - 30; // 07:59:30
4741        let mut acks = std::collections::BTreeMap::new();
4742        // R1: the desk opened at 07:59:30; the phone read its opening only at 08:02 (its notes said last night's).
4743        acks.insert("phone-id".to_string(), due - 36_000);
4744        let lead = word_lead(&desk_knows);
4745        assert!(!all_acknowledged(&desk_knows, opened, &acks, due));
4746        let acked = acked_after(None, all_acknowledged(&desk_knows, opened, &acks, due), opened, due + 5);
4747        let first = first_here(false, true, opened, acked.map(|(_, at)| at), due, lead);
4748        assert!(!first, "the phone has not said it read this opening: not the first");
4749        assert_eq!(plan(None, true, first, due, due + 5, true), Plan::Quiet(due + QUIET_AFTER), "R1: quiet around the phone's alarm, promised");
4750        // The phone at 08:00:05, keeping the doses itself: the desk closed, its own pull through: the first, plainly.
4751        assert_eq!(others(&key, due, due + 5, "phone-id", true, Some(&nothing), &[closed_desk(0)], true), Others::First);
4752        // The desk after the quiet: the doubt first, the phone named.
4753        assert_eq!(plan(Some(Tried { quiet: due + QUIET_AFTER, ..Tried::default() }), true, first, due, due + QUIET_AFTER + 60, true), Plan::Late(None));
4754        let from_desk = match others(&key, due, due + QUIET_AFTER + 60, "desk-id", false, Some(&nothing), std::slice::from_ref(&phone), false) {
4755            Others::Doubt(e) => elsewhere_said(&e, due, &english),
4756            other => panic!("{other:?}"),
4757        };
4758        assert_eq!(from_desk, "the phone may have reminded you too, by its own alarm");
4759        // R1b, R1c: a network slow this time, a restart: the same: its new opening said by no phone before 08:00.
4760        acks.insert("phone-id".to_string(), opened);
4761        let late_ack = acked_after(None, all_acknowledged(&desk_knows, opened, &acks, due), opened, due + 245);
4762        assert!(!first_here(false, true, opened, late_ack.map(|(_, at)| at), due, lead), "said at 08:04: too late for this dose");
4763        let early = acked_after(None, all_acknowledged(&desk_knows, opened, &acks, due), opened, due - lead - 10);
4764        assert!(first_here(false, true, opened, early.map(|(_, at)| at), due, lead), "said before 08:00 (and the clocks' lead): the desk is the first; the phone then knows it open");
4765        let just = acked_after(None, true, opened, due - 10);
4766        assert!(!first_here(false, true, opened, just.map(|(_, at)| at), due, lead), "said ten seconds before the dose: a phone ahead may have decided already (L1)");
4767        assert_eq!(acked_after(early, true, opened, due + 600), early, "kept for the same opening");
4768        assert_eq!(acked_after(early, true, opened + 60, due + 600), Some((opened + 60, due + 600)), "a new opening, said anew");
4769        // R1d: the phone read the desk's claim (renewed at 07:59:30, open) before its entry: not its word.
4770        match others(&key, due, due + 600, "phone-id", true, Some(&nothing), &[closed_desk(opened)], true) {
4771            Others::Doubt(e) => assert_eq!(elsewhere_said(&e, due, &english), format!("desk closed {}, and its news can be slow to come", when_said(&Timestamp::from_second(due - 32_400).unwrap().to_zoned(TimeZone::system()), &english))),
4772            other => panic!("{other:?}"),
4773        }
4774        // The phone's own pull not through since the dose: not its word either.
4775        assert!(matches!(others(&key, due, due + 600, "phone-id", true, Some(&nothing), &[closed_desk(0)], false), Others::Doubt(_)));
4776        // R2: the key not read, the claim or the opening not written: never the first, whatever it kept.
4777        assert!(!first_here(false, false, due - 3_600, Some(due - 3_600), due, lead));
4778        // R3: a phone in the folder, counted or not (silent a while): quiet all the same, and its word asked (C1b).
4779        let silent_phone = Peer { seen: due - 30 * 86_400, said: Some(Said { exported: due - 30 * 86_400, ..phone.said.clone().unwrap() }), ..phone.clone() };
4780        assert!(phone_alarms(&Knowledge { record_lost: None, peers: vec![silent_phone.clone()] }) && must_acknowledge(&Knowledge { record_lost: None, peers: vec![silent_phone] }).len() == 1);
4781        // R5: the doses kept elsewhere a moment: nothing promised; back within the grace, tried again, the doubt first.
4782        assert_eq!(plan(Some(Tried { unshown: due + 5, ..Tried::default() }), true, true, due, due + 180, true), Plan::Late(Some(sioul_sync::notify::Late::Retry)));
4783        // R9: alone in the folder (no other device at all), started at 08:00:02: the first, plainly.
4784        assert!(first_here(true, true, due + 2, None, due, lead));
4785        assert_eq!(plan(None, true, true, due, due + 2, false), Plan::First);
4786    }
4787
4788    /// The seventh review's timelines: the word asked of every phone in the
4789    /// folder not gone (counted or not, said off or not, silent or not), and
4790    /// dropped by a phone that stops counting the computer; a computer back
4791    /// from a gap opens anew; a computer never takes another's "closed"; a
4792    /// word names this very opening or says nothing; a device without an
4793    /// entry is never "in use".
4794    #[test]
4795    fn every_phone_not_gone_must_say_it_read_this_opening() {
4796        use sioul_core::dose_reminders::{QUIET_AFTER, others};
4797        use sioul_core::health::Said;
4798        let due: i64 = 1_791_360_000; // 08:00
4799        let key = format!("levo@{due}");
4800        let english = Translator::new("en");
4801        let phone = |seen: i64, off: bool, forgotten: bool| Peer {
4802            name: "phone".into(),
4803            id: "phone-id".into(),
4804            said: Some(Said { started: seen - 600, closed: seen, working: false, exported: seen, imported: seen, doses: true, left: false, phone: true }),
4805            complete: true,
4806            seen,
4807            heard: seen,
4808            off: off || forgotten,
4809            forgotten,
4810            ..Peer::default()
4811        };
4812        let opened = due - 3 * 86_400; // the laptop's opening, three days old
4813        let acks = |opening: i64| [("phone-id".to_string(), opening)].into_iter().collect::<std::collections::BTreeMap<String, i64>>();
4814        // C1a: the laptop said off on the phone while asleep: the phone drops its word (it no longer counts it);
4815        // the laptop, woken, asks the word of the phone all the same: not the first.
4816        let off_there = phone(due - 3_600, false, false);
4817        let knows = Knowledge { record_lost: None, peers: vec![off_there.clone()] };
4818        assert!(!all_acknowledged(&knows, opened, &std::collections::BTreeMap::new(), due), "no word: not the first");
4819        // C1b, C1c: the phone silent eight days here, or said off here: its word asked all the same.
4820        for phone in [phone(due - 8 * 86_400, false, false), phone(due - 3_600, true, false)] {
4821            let fresh = due - phone.seen <= WORD_FRESH;
4822            let knows = Knowledge { record_lost: None, peers: vec![phone] };
4823            assert_eq!(must_acknowledge(&knows).len(), 1);
4824            assert!(!all_acknowledged(&knows, opened, &std::collections::BTreeMap::new(), due));
4825            assert_eq!(all_acknowledged(&knows, opened, &acks(opened), due), fresh, "its word on this very opening, while it is heard here (N2)");
4826        }
4827        // Forgotten here, or stopped sharing: no word owed.
4828        assert!(must_acknowledge(&Knowledge { record_lost: None, peers: vec![phone(due - 3_600, false, true)] }).is_empty());
4829        let left = Peer { said: Some(Said { left: true, ..phone(due - 3_600, false, false).said.unwrap() }), ..phone(due - 3_600, false, false) };
4830        assert!(must_acknowledge(&Knowledge { record_lost: None, peers: vec![left] }).is_empty());
4831        // An older or unknown device (no entry): its word never comes: never the first.
4832        let older = Peer { id: "old-id".into(), name: "FP5".into(), heard: due - 600, ..Peer::default() };
4833        assert!(!all_acknowledged(&Knowledge { record_lost: None, peers: vec![older.clone()] }, opened, &acks(opened), due));
4834        // M1: a word naming another opening, later or impossible, says nothing.
4835        let knows = Knowledge { record_lost: None, peers: vec![phone(due - 3_600, false, false)] };
4836        assert!(!all_acknowledged(&knows, opened, &acks(opened + 3_000), due));
4837        assert!(!all_acknowledged(&knows, opened, &acks(i64::MAX), due));
4838        // C1c: a laptop asleep (no tick for a while) opens anew at waking: the old word is for another opening.
4839        assert!(woke_from_gap(due - 600, due - 30) && !woke_from_gap(due - 90, due - 30) && !woke_from_gap(0, due), "three minutes without a tick: a new opening");
4840        // C1d: a desk not first (the phone not counted there): quiet, then never plain (C2).
4841        assert_eq!(plan(None, true, false, due, due + 180, true), Plan::Quiet(due + QUIET_AFTER));
4842        assert_eq!(plan(Some(Tried { quiet: due + QUIET_AFTER, ..Tried::default() }), true, false, due, due + QUIET_AFTER + 60, true), Plan::Late(None));
4843        // H1 (C2): two computers, no phone: another computer's "closed" is never the desk's proof.
4844        let laptop = Peer {
4845            name: "laptop".into(),
4846            id: "laptop-id".into(),
4847            said: Some(Said { started: due - 36_000, closed: due - 32_400, working: false, exported: due - 32_400, imported: due - 32_400, doses: true, left: false, phone: false }),
4848            complete: true,
4849            seen: due - 32_400,
4850            heard: due - 32_400,
4851            acknowledged: due - 36_000,
4852            ..Peer::default()
4853        };
4854        match others(&key, due, due + 180, "desk-id", false, Some(&DoseReminders::default()), std::slice::from_ref(&laptop), true) {
4855            Others::Doubt(e) => assert!(elsewhere_said(&e, due, &english).starts_with("laptop closed "), "{e:?}"),
4856            other => panic!("{other:?}"),
4857        }
4858        // L3: a device without an entry, named: it may have reminded by its own alarm, never "was in use".
4859        match others(&key, due, due + 600, "phone-id", true, Some(&DoseReminders::default()), std::slice::from_ref(&older), true) {
4860            Others::Doubt(e) => assert_eq!(elsewhere_said(&e, due, &english), "FP5 may have reminded you too, by its own alarm"),
4861            other => panic!("{other:?}"),
4862        }
4863    }
4864
4865    /// The eighth review's timelines. N1: a desk that runs, offline from
4866    /// 06:00, said off on the phone at 07:05: the phone's word, taken back
4867    /// there, still names the desk's opening here, but the phone was last
4868    /// heard here at 05:50: past `WORD_FRESH` its word says nothing, the desk
4869    /// is not the dose's first (quiet, then the doubt). N2: the sharing down
4870    /// both ways eight days: the same. N3: the phone's word never goes back:
4871    /// an older copy of the desk's entry put back is not its word ("closed"):
4872    /// what the desk says comes from its newest copy (in use since 07:30).
4873    #[test]
4874    fn a_phones_word_counts_while_it_is_heard_and_never_goes_back() {
4875        use sioul_core::dose_reminders::{QUIET_AFTER, others};
4876        use sioul_core::health::Said;
4877        let due: i64 = 1_791_360_000; // 08:00
4878        let key = format!("levo@{due}");
4879        let english = Translator::new("en");
4880        let opened = due - 82_800; // the desk opened yesterday at 09:00, acknowledged then
4881        let phone = |seen: i64| Peer {
4882            name: "phone".into(),
4883            id: "phone-id".into(),
4884            said: Some(Said { started: seen - 600, closed: seen, working: false, exported: seen, imported: seen, doses: true, left: false, phone: true }),
4885            complete: true,
4886            seen,
4887            heard: seen,
4888            ..Peer::default()
4889        };
4890        let word = [("phone-id".to_string(), opened)].into_iter().collect::<std::collections::BTreeMap<String, i64>>();
4891        // Heard an hour ago: its word counts.
4892        assert!(all_acknowledged(&Knowledge { record_lost: None, peers: vec![phone(due - 3_600)] }, opened, &word, due));
4893        // N1: last heard at 05:50, the desk offline since 06:00: not the first at 08:00, quiet then the doubt.
4894        let n1 = Knowledge { record_lost: None, peers: vec![phone(due - 7_800)] };
4895        assert!(!all_acknowledged(&n1, opened, &word, due));
4896        let acked = acked_after(Some((opened, opened + 180)), all_acknowledged(&n1, opened, &word, due), opened, due);
4897        assert_eq!(acked, None, "the old time forgotten with the word");
4898        let first = first_here(false, true, opened, acked.map(|(_, at)| at), due, word_lead(&n1));
4899        assert!(!first);
4900        assert_eq!(plan(None, true, first, due, due + 5, true), Plan::Quiet(due + QUIET_AFTER));
4901        // N2: eight days of silence both ways: the same.
4902        assert!(!all_acknowledged(&Knowledge { record_lost: None, peers: vec![phone(due - 8 * 86_400)] }, opened, &word, due));
4903        // N3: the desk opened at 07:30, read on the phone at 07:32; at 08:00:05 the phone is served last night's entry.
4904        let mut kept = Peers::default();
4905        let desk_entry = |started: i64, closed: i64, working: bool| sioul_sync::devices::Entry { id: "desk-id".into(), name: "desk".into(), kind: sioul_sync::devices::COMPUTER.into(), started, closed, working, exported: started + 60, doses: true, ..sioul_sync::devices::Entry::default() };
4906        learn(&mut kept, &[], &[desk_entry(due - 1_800, 0, true)], &sioul_sync::share::Heard::default(), due - 1_680);
4907        learn(&mut kept, &[], &[desk_entry(due - 32_400 - 3_600, due - 32_400, false)], &sioul_sync::share::Heard::default(), due + 5);
4908        let desk = kept.peers.get("desk-id").unwrap().peer.clone();
4909        assert_eq!(desk.latest_opening, due - 1_800, "never lowered by the older copy");
4910        let said = desk.said.clone().unwrap();
4911        assert_eq!(said.started.max(desk.latest_opening), due - 1_800, "the phone's word stays on 07:30's opening");
4912        assert!(said.working && said.started == due - 1_800, "what it says comes from its newest copy (the ninth review's W1)");
4913        let desk = Peer { acknowledged: due - 1_800, complete: true, ..desk };
4914        match others(&key, due, due + 5, "phone-id", true, Some(&DoseReminders::default()), std::slice::from_ref(&desk), true) {
4915            Others::Doubt(e) => assert!(elsewhere_said(&e, due, &english).starts_with("desk was in use and may have reminded you"), "{e:?}"),
4916            other => panic!("the older copy taken as its word: {other:?}"),
4917        }
4918        // L1: one reason said once, however many ways it came.
4919        assert_eq!(joined(vec!["desk closed".into(), "desk closed".into(), "the phone may have reminded you".into()], &english), "desk closed; the phone may have reminded you");
4920    }
4921
4922    /// The ninth review of battery part C (15 October 2026). W1: two
4923    /// genuine older copies of the phone's entry, both naming the desk's
4924    /// opening, served in turn each hour for six hours: no news of the phone,
4925    /// its word expires, the desk is not the first; and the word comes from the
4926    /// newest copy read, never an older one put back. F1: a computer's copy
4927    /// dated a year ahead of this clock is no news and leaves nothing kept;
4928    /// an opening kept from such a clock is dropped. A `seen` in the future
4929    /// (this clock set back) is no news of the phone either.
4930    #[test]
4931    fn only_a_newer_copy_of_an_entry_is_news() {
4932        let t0: i64 = 1_791_349_200; // 05:00
4933        let opening = t0 - 72_000;
4934        let copy = |exported: i64, acked: bool| sioul_sync::devices::Entry {
4935            id: "phone-id".into(),
4936            kind: sioul_sync::devices::PHONE.into(),
4937            started: t0 - 3_600,
4938            closed: t0 - 3_000,
4939            exported,
4940            imported: exported,
4941            doses: true,
4942            acked: if acked { [("desk-id".to_string(), opening)].into_iter().collect() } else { std::collections::BTreeMap::new() },
4943            ..sioul_sync::devices::Entry::default()
4944        };
4945        let none = sioul_sync::share::Heard::default();
4946        let mut kept = Peers::default();
4947        // Its 05:50 restatement, read at 05:51.
4948        learn(&mut kept, &[], &[copy(t0 + 3_000, true)], &none, t0 + 3_060);
4949        // Six hours with no newer copy, the two older ones served in turn, one an hour.
4950        for h in 1..=6 {
4951            learn(&mut kept, &[], &[copy(if h % 2 == 1 { t0 - 600 } else { t0 + 3_000 }, true)], &none, t0 + 3_060 + h * 3_600);
4952        }
4953        let knows = Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
4954        let acks = [("phone-id".to_string(), opening)].into_iter().collect();
4955        let at = t0 + 3_060 + 6 * 3_600 + 60;
4956        assert!(!all_acknowledged(&knows, opening, &acks, at), "an older copy put back is no news of the phone");
4957        assert_eq!(kept.peers["phone-id"].peer.seen, t0 + 3_060, "seen when its newest copy came, not since");
4958        // Its word taken back in a newer copy, then the older one with the word served again: the newest copy's word.
4959        learn(&mut kept, &[], &[copy(t0 + 3_600 * 7, false)], &none, t0 + 3_600 * 7 + 60);
4960        learn(&mut kept, &[], &[copy(t0 + 3_000, true)], &none, t0 + 3_600 * 7 + 120);
4961        assert!(acknowledgements("desk-id", &newest_of(&kept)).is_empty(), "the word from the newest copy read, never an older one");
4962        // Its entry not read any more: its word, kept, says nothing.
4963        learn(&mut kept, &[], &[copy(t0 + 3_600 * 8, true)], &none, t0 + 3_600 * 8 + 60);
4964        assert_eq!(acknowledgements("desk-id", &newest_of(&kept)).get("phone-id"), Some(&opening));
4965        learn(&mut kept, &[], &[], &none, t0 + 3_600 * 8 + 120);
4966        assert!(acknowledgements("desk-id", &newest_of(&kept)).is_empty(), "a word no longer read says nothing");
4967        // A clock set back here: a `seen` in the future is no news of the phone.
4968        let fresh = Knowledge { record_lost: None, peers: vec![Peer { seen: at + 3_600, ..knows.peers[0].clone() }] };
4969        assert!(!all_acknowledged(&fresh, opening, &acks, at));
4970        // F1: the desk's entry from a clock a year ahead: no news, nothing kept; set right, counted again.
4971        let desk = |started: i64| sioul_sync::devices::Entry { id: "desk-id".into(), name: "desk".into(), kind: sioul_sync::devices::COMPUTER.into(), started, closed: started + 600, exported: started + 590, imported: started + 590, doses: true, ..sioul_sync::devices::Entry::default() };
4972        let year = 400 * 86_400;
4973        let mut kept = Peers::default();
4974        learn(&mut kept, &[], &[desk(t0 + year)], &none, t0 + 3_660);
4975        assert!(kept.peers["desk-id"].entry.is_none() && kept.peers["desk-id"].peer.latest_opening == 0, "nothing kept from a clock a year ahead");
4976        // Settings says why it is not heard, so that its clock is set right.
4977        let english = Translator::new("en");
4978        let rows = |kept: &Peers, now: i64| device_rows_of(&kept.peers.values().map(|s| s.peer.clone()).collect::<Vec<_>>(), kept, now, &english);
4979        assert_eq!(rows(&kept, t0 + 3_660)[0].state, "Its clock runs more than ten minutes ahead of this one's: what it says is set aside here until its clock is set right.");
4980        learn(&mut kept, &[], &[desk(t0 + 4_800)], &none, t0 + 5_460);
4981        assert_eq!(kept.peers["desk-id"].peer.latest_opening, t0 + 4_800, "set right: its opening counts");
4982        let row = &rows(&kept, t0 + 5_460)[0];
4983        assert!(row.state.starts_with("Closed ") && row.notes.is_empty(), "{row:?}");
4984        // An opening kept before this rule from a clock far ahead: dropped once seen in the future.
4985        kept.peers.get_mut("desk-id").unwrap().peer.latest_opening = t0 + year;
4986        learn(&mut kept, &[], &[desk(t0 + 4_800)], &none, t0 + 5_520);
4987        assert_eq!(kept.peers["desk-id"].peer.latest_opening, t0 + 4_800);
4988    }
4989
4990    /// The final review before 0.0.6 (15 October 2026), F1H: the desk hears
4991    /// the phone but cannot send; at 06:40 the phone's clock jumps a quarter
4992    /// of an hour ahead, and at 07:05 "This device is off" is said there of
4993    /// the desk: the word goes, in copies the desk sets aside. The desk keeps
4994    /// the 06:30 copy, word included, but a phone whose copy read now is set
4995    /// aside gives no word: at 08:00 the desk is not the first (quiet, then
4996    /// the doubt, which says why), and the phone alone reminds plainly. The
4997    /// smaller windows of the same class: a copy without the word written in
4998    /// the same second as the one before (W1S), and one of a phone whose clock
4999    /// was set back half an hour (W1B), take the word away at once, no news
5000    /// though they are; and a copy that is no news never gives a word.
5001    #[test]
5002    fn a_copy_that_is_no_news_takes_a_word_away_and_never_gives_one() {
5003        use sioul_core::dose_reminders::QUIET_AFTER;
5004        let due: i64 = 1_791_360_000; // 08:00
5005        let opening = due - 82_800; // the desk opened yesterday at 09:00, the phone's word on it since
5006        let none = sioul_sync::share::Heard::default();
5007        let copy = |exported: i64, word: bool| sioul_sync::devices::Entry {
5008            id: "phone-id".into(),
5009            kind: sioul_sync::devices::PHONE.into(),
5010            started: due - 10_800,
5011            closed: due - 10_700,
5012            exported,
5013            imported: exported,
5014            doses: true,
5015            acked: if word { [("desk-id".to_string(), opening)].into_iter().collect() } else { std::collections::BTreeMap::new() },
5016            ..sioul_sync::devices::Entry::default()
5017        };
5018        // The phone's word as the tick reads it.
5019        let word_here = |kept: &Peers| acknowledgements("desk-id", &newest_of(kept));
5020        // F1H. Its 06:30 restatement, read at 06:31: the word stands.
5021        let mut kept = Peers::default();
5022        learn(&mut kept, &[], &[copy(due - 5_400, true)], &none, due - 5_340);
5023        assert_eq!(word_here(&kept).get("phone-id"), Some(&opening));
5024        // From 06:40 its clock runs a quarter of an hour ahead: its copies set aside, the word taken back in them at 07:05.
5025        for (real, word) in [(due - 4_740, true), (due - 3_300, false), (due - 1_800, false), (due - 60, false)] {
5026            learn(&mut kept, &[], &[copy(real + 900, word)], &none, real + 30);
5027        }
5028        let seen = &kept.peers["phone-id"];
5029        assert!(seen.peer.set_aside && seen.entry.as_ref().is_some_and(|e| e.acked.get("desk-id") == Some(&opening)), "the 06:30 copy kept, its word in it");
5030        assert!(word_here(&kept).is_empty(), "a phone whose copy read now is set aside gives no word");
5031        let knows = Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
5032        let at = due - 30;
5033        assert!(!all_acknowledged(&knows, opening, &word_here(&kept), at));
5034        let acked = acked_after(Some((opening, opening + 180)), all_acknowledged(&knows, opening, &word_here(&kept), at), opening, at);
5035        let first = first_here(false, true, opening, acked.map(|(_, at)| at), due, word_lead(&knows));
5036        assert!(!first);
5037        assert_eq!(plan(None, true, first, due, due + 10, true), Plan::Quiet(due + QUIET_AFTER), "quiet, then the doubt: the phone's reminder the only plain one");
5038        let english = Translator::new("en");
5039        assert_eq!(why_in(&sioul_core::health::doubts_now(due, due + QUIET_AFTER, None, &knows.peers), &english), "the phone has its clock more than ten minutes ahead of this one's, so what it says is set aside here");
5040        // W1S: a copy without the word written in the same second as the one before: no news, and no word.
5041        let mut kept = Peers::default();
5042        learn(&mut kept, &[], &[copy(due - 3_600, true)], &none, due - 3_590);
5043        learn(&mut kept, &[], &[copy(due - 3_600, false)], &none, due - 3_580);
5044        assert_eq!(kept.peers["phone-id"].peer.seen, due - 3_590, "no news");
5045        assert!(word_here(&kept).is_empty(), "the word taken back at once");
5046        // W1B: its clock set back half an hour at 07:01. A copy saying the word as the newest does keeps it;
5047        // the one taking it back at 07:05 takes it away at once, no news though both are.
5048        let mut kept = Peers::default();
5049        learn(&mut kept, &[], &[copy(due - 3_600, true)], &none, due - 3_590);
5050        learn(&mut kept, &[], &[copy(due - 3_480 - 1_800, true)], &none, due - 3_470);
5051        assert_eq!(word_here(&kept).get("phone-id"), Some(&opening), "said by both: it stands");
5052        learn(&mut kept, &[], &[copy(due - 3_300 - 1_800, false)], &none, due - 3_290);
5053        assert_eq!(kept.peers["phone-id"].peer.seen, due - 3_590, "no news");
5054        assert!(word_here(&kept).is_empty(), "taken away at 07:05, not at 07:31");
5055        // A copy that is no news never gives a word: the newest says none, an older one put back says it.
5056        let mut kept = Peers::default();
5057        learn(&mut kept, &[], &[copy(due - 3_600, false)], &none, due - 3_590);
5058        learn(&mut kept, &[], &[copy(due - 7_200, true)], &none, due - 3_500);
5059        assert!(word_here(&kept).is_empty());
5060    }
5061
5062    /// The final review's F1L: a device said off and forgotten here stays so
5063    /// while this clock goes back an hour and is set right again (its copy
5064    /// then looks ahead: set aside, never dropped), until it says anything
5065    /// newer. A copy kept from a clock already far ahead when it came (by a
5066    /// Sioul before the rule) is dropped, and the copy read now taken.
5067    #[test]
5068    fn a_device_said_off_stays_so_until_it_says_anything_newer() {
5069        let t: i64 = 1_791_349_200;
5070        let desk = |started: i64, exported: i64| sioul_sync::devices::Entry { id: "desk-id".into(), name: "desk".into(), kind: sioul_sync::devices::COMPUTER.into(), started, closed: exported + 10, exported, imported: exported, doses: true, ..sioul_sync::devices::Entry::default() };
5071        let none = sioul_sync::share::Heard::default();
5072        let mut kept = Peers::default();
5073        learn(&mut kept, &[], &[desk(t, t + 600)], &none, t + 660);
5074        let mark = Mark { started: t, exported: t + 600, renewed: 0, at: t + 700 };
5075        let seen = kept.peers.get_mut("desk-id").unwrap();
5076        (seen.off, seen.forgotten) = (Some(mark.clone()), Some(mark));
5077        // This clock set back an hour: the same copy looks ahead, set aside; kept, and the marks with it.
5078        learn(&mut kept, &[], &[desk(t, t + 600)], &none, t - 2_900);
5079        let seen = &kept.peers["desk-id"];
5080        assert!(seen.peer.set_aside && seen.entry.is_some() && seen.peer.off && seen.peer.forgotten);
5081        // Set right again: the same copy, nothing newer.
5082        learn(&mut kept, &[], &[desk(t, t + 600)], &none, t + 800);
5083        let seen = &kept.peers["desk-id"];
5084        assert!(!seen.peer.set_aside && seen.peer.off && seen.peer.forgotten);
5085        // It speaks again: a new start.
5086        learn(&mut kept, &[], &[desk(t + 900, t + 960)], &none, t + 1_000);
5087        let seen = &kept.peers["desk-id"];
5088        assert!(!seen.peer.off && !seen.peer.forgotten, "it spoke again");
5089        // A copy kept from a clock already a day ahead when it came: dropped, the copy read now taken.
5090        let mut kept = Peers::default();
5091        learn(&mut kept, &[], &[desk(t, t + 600)], &none, t + 660);
5092        kept.peers.get_mut("desk-id").unwrap().entry = Some(desk(t + 86_400, t + 86_400));
5093        learn(&mut kept, &[], &[desk(t + 700, t + 760)], &none, t + 800);
5094        assert_eq!(kept.peers["desk-id"].entry.as_ref().map(|e| e.started), Some(t + 700));
5095    }
5096
5097    /// A phone that will never share again (the owner's, 16 October 2026: its
5098    /// battery dead, its USB broken), and the new phone that replaces it
5099    /// (`devices::Entry::replaces`), or the computer's "Forget it now": never
5100    /// two plain reminders of a dose, nor none. Silent, the old phone reminds
5101    /// nothing; replaced or forgotten, it owes the desk no word on its opening,
5102    /// and the desk is the dose's first again once the new phone said it read
5103    /// that opening; until then it keeps quiet around the phone's alarm, then
5104    /// reminds with the doubt. The new phone's alarm owes the old one nothing
5105    /// either: the desk's own word decides. Should the old phone speak again
5106    /// (said replaced by mistake), it counts again at once: the desk owes its
5107    /// word again, and is not the first until it gives it. An older copy of
5108    /// its entry put back is no news; nothing of it is deleted.
5109    #[test]
5110    fn a_replaced_or_forgotten_phone_never_makes_two_plain_reminders_nor_none() {
5111        use sioul_core::dose_reminders::{DoseReminders, QUIET_AFTER, Tried, others};
5112        use sioul_sync::devices::{Entry, PHONE, Replaced};
5113        let due: i64 = 1_791_360_000; // 08:00
5114        let key = format!("levo@{due}");
5115        let opened = due - 3_600; // the desk opened at 07:00, the doses kept there since
5116        let none = sioul_sync::share::Heard::default();
5117        let phone = |id: &str, started: i64, exported: i64, acked: Option<i64>| Entry {
5118            id: id.into(),
5119            name: format!("{id} model"),
5120            kind: PHONE.into(),
5121            started,
5122            closed: exported + 5,
5123            working: false,
5124            imported: exported,
5125            exported,
5126            doses: true,
5127            acked: acked.map(|opening| [("desk-id".to_string(), opening)].into_iter().collect()).unwrap_or_default(),
5128            ..Entry::default()
5129        };
5130        let knows = |kept: &Peers| Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
5131        let learned = |kept: &mut Peers, entries: &[Entry], now: i64| {
5132            learn(kept, &[], entries, &none, now);
5133            let replaced = replacements(kept, entries, &Entry::default());
5134            apply_replaced(kept, &replaced);
5135        };
5136        let must = |kept: &Peers| must_acknowledge(&knows(kept)).iter().map(|p| p.id.clone()).collect::<Vec<_>>();
5137        // The old phone, silent since three days, its word on an opening of the desk long gone.
5138        let old = phone("old-id", due - 3 * 86_400 - 600, due - 3 * 86_400, Some(due - 4 * 86_400));
5139        let mut kept = Peers::default();
5140        learned(&mut kept, std::slice::from_ref(&old), due - 3 * 86_400 + 60);
5141        assert_eq!(must(&kept), ["old-id"], "silent, its word is asked all the same");
5142        assert!(!all_acknowledged(&knows(&kept), opened, &acknowledgements("desk-id", std::slice::from_ref(&old)), due - 600), "never given: the desk is never the first");
5143        // The new phone joins in its place, its entry saying so, with what the old one last said.
5144        let replaces = Replaced { id: "old-id".into(), started: old.started, heard: (old.closed.max(old.exported)) * 1000 };
5145        let new = Entry { replaces: vec![replaces.clone()], ..phone("new-id", due - 1_800, due - 1_700, None) };
5146        learned(&mut kept, &[old.clone(), new.clone()], due - 1_690);
5147        let seen_old = kept.peers["old-id"].peer.clone();
5148        assert!(seen_old.off && seen_old.forgotten, "replaced: as forgotten here");
5149        assert_eq!(must(&kept), ["new-id"], "the old phone owes no word any more");
5150        // An older copy of the old phone's entry put back by whoever serves the files: no news, still replaced.
5151        let older = phone("old-id", due - 5 * 86_400, due - 5 * 86_400 + 60, None);
5152        learned(&mut kept, &[older, new.clone()], due - 1_680);
5153        assert!(kept.peers["old-id"].peer.forgotten, "an older copy is no news");
5154        // Before the new phone said it read the desk's opening: not the first; quiet around
5155        // the phone's alarm, then the doubt first: never plain, never none.
5156        let acks = acknowledgements("desk-id", &[old.clone(), new.clone()]);
5157        assert!(!all_acknowledged(&knows(&kept), opened, &acks, due - 1_680));
5158        assert_eq!(plan(None, true, false, due, due + 180, true), Plan::Quiet(due + QUIET_AFTER));
5159        assert_eq!(plan(Some(Tried { quiet: due + QUIET_AFTER, ..Tried::default() }), true, false, due, due + QUIET_AFTER + 60, true), Plan::Late(None));
5160        // The new phone said it read this very opening: the desk is the first again, plainly, at the dose's time.
5161        let new = Entry { replaces: vec![replaces.clone()], ..phone("new-id", due - 1_800, due - 1_200, Some(opened)) };
5162        learned(&mut kept, &[old.clone(), new.clone()], due - 1_190);
5163        let acks = acknowledgements("desk-id", &[old.clone(), new.clone()]);
5164        let knowledge = knows(&kept);
5165        assert!(all_acknowledged(&knowledge, opened, &acks, due - 1_190));
5166        assert!(first_here(false, true, opened, Some(due - 1_190), due, word_lead(&knowledge)));
5167        assert_eq!(plan(None, true, true, due, due + 30, true), Plan::First);
5168        // The new phone's alarm: the old phone, replaced there too, owes it no word; the desk's own decides.
5169        let desk = Peer {
5170            name: "desk".into(),
5171            id: "desk-id".into(),
5172            said: Some(sioul_core::health::Said { started: opened, closed: 0, working: true, exported: due + 570, imported: due + 570, doses: true, left: false, phone: false }),
5173            complete: true,
5174            seen: due + 575,
5175            heard: due + 570,
5176            ..Peer::default()
5177        };
5178        let gone = Peer { off: true, forgotten: true, ..seen_old.clone() };
5179        let mut shown = DoseReminders::default();
5180        *shown.entry(&key, "desk-id") = Tried { maybe: due + 2, shown: due + 3, ..Tried::default() };
5181        assert!(matches!(others(&key, due, due + WAIT_FOR_NEWS, "new-id", true, Some(&shown), &[desk.clone(), gone.clone()], true), Others::Reminded), "the desk reminded it: the phone stays silent");
5182        let mut quiet = DoseReminders::default();
5183        *quiet.entry(&key, "desk-id") = Tried { quiet: due + QUIET_AFTER, ..Tried::default() };
5184        assert!(matches!(others(&key, due, due + WAIT_FOR_NEWS, "new-id", true, Some(&quiet), &[desk.clone(), gone.clone()], true), Others::First), "the desk promised none: the phone's alarm is the first");
5185        let counted = Peer { off: false, forgotten: false, ..gone.clone() };
5186        assert!(matches!(others(&key, due, due + WAIT_FOR_NEWS, "new-id", true, Some(&quiet), &[desk, counted], true), Others::Doubt(_)), "counted, the old phone's alarm could ring: the doubt");
5187        // The old phone speaks again (said replaced by mistake): a new start. It counts again at
5188        // once, and owes its word on the desk's opening: the desk is not the first until it gives it.
5189        let back = phone("old-id", due - 900, due - 890, Some(due - 4 * 86_400));
5190        learned(&mut kept, &[back.clone(), new.clone()], due - 880);
5191        let seen_back = kept.peers["old-id"].peer.clone();
5192        assert!(!seen_back.off && !seen_back.forgotten, "it spoke again: counted");
5193        assert_eq!(must(&kept).len(), 2);
5194        assert!(!all_acknowledged(&knows(&kept), opened, &acknowledgements("desk-id", &[back.clone(), new.clone()]), due - 880), "never plain beside it");
5195        // Its word on this opening, given: the desk the first again.
5196        let back = phone("old-id", due - 900, due - 860, Some(opened));
5197        learned(&mut kept, &[back.clone(), new.clone()], due - 850);
5198        assert!(all_acknowledged(&knows(&kept), opened, &acknowledgements("desk-id", &[back, new]), due - 850));
5199        // "Forget it now" on the desk, the same for a phone silent a day: forgotten until it says anything newer.
5200        let mut kept = Peers::default();
5201        let silent = phone("old-id", due - 86_400 - 600, due - 86_400, Some(due - 2 * 86_400));
5202        learned(&mut kept, std::slice::from_ref(&silent), due - 86_400 + 60);
5203        let seen = kept.peers.get_mut("old-id").unwrap();
5204        seen.forgotten = Some(Mark { started: silent.started, exported: silent.exported, renewed: 0, at: due - 600 });
5205        learned(&mut kept, std::slice::from_ref(&silent), due - 590);
5206        assert!(must(&kept).is_empty() && kept.peers["old-id"].peer.forgotten);
5207        let rows = device_rows_of(&knows(&kept).peers, &kept, due - 590, &Translator::new("en"));
5208        assert!(rows.iter().all(|r| r.id != "old-id"), "forgotten: not listed");
5209        learned(&mut kept, &[phone("old-id", due - 300, due - 290, None)], due - 280);
5210        assert_eq!(must(&kept), ["old-id"], "it spoke again: its word owed again");
5211        // What the replacement says: still that start, nothing later than its time.
5212        assert!(replaces.holds(Some(old.started), old.exported * 1000) && !replaces.holds(Some(due - 900), old.exported * 1000) && !replaces.holds(Some(old.started), (old.exported + 60) * 1000));
5213    }
5214
5215    /// On a computer, a phone silent for a day or more is offered "Forget it
5216    /// now" (the owner's, 16 October 2026: a dead phone held every dose back a
5217    /// week); one heard within the day, or already offered "Forget this
5218    /// device", is not; nor is a computer.
5219    #[test]
5220    fn a_phone_silent_a_day_is_offered_to_be_forgotten_now() {
5221        use sioul_core::health::Said;
5222        let now: i64 = 1_791_360_000;
5223        let peer = |id: &str, phone: bool, seen: i64| Peer {
5224            id: id.into(),
5225            name: id.into(),
5226            said: Some(Said { started: seen - 60, closed: seen, working: false, exported: seen, imported: seen, doses: true, left: false, phone }),
5227            complete: true,
5228            seen,
5229            heard: seen,
5230            ..Peer::default()
5231        };
5232        let peers = [peer("silent-phone", true, now - FORGET_NOW_AFTER - 60), peer("fresh-phone", true, now - 3_600), peer("silent-desk", false, now - FORGET_NOW_AFTER - 60), peer("week-phone", true, now - 8 * 86_400)];
5233        let rows = device_rows_of(&peers, &Peers::default(), now, &Translator::new("en"));
5234        let row = |id: &str| rows.iter().find(|r| r.id == id).unwrap();
5235        assert!(row("silent-phone").forget_now && !row("silent-phone").forget);
5236        assert!(!row("fresh-phone").forget_now);
5237        assert!(!row("silent-desk").forget_now, "a computer is not offered it");
5238        assert!(row("week-phone").forget && !row("week-phone").forget_now, "silent a week: Forget this device, as before");
5239    }
5240
5241    /// The final review's words: on the phone, a desk whose clock runs an hour
5242    /// ahead (its copies set aside, last night's kept, its claims dated ahead)
5243    /// is said as it is, never "an older Sioul there?", in the alarm's reason
5244    /// and the doubt alike, said once; its word stays on last night's opening,
5245    /// so the desk, opened anew, is never the first.
5246    #[test]
5247    fn a_computer_whose_clock_runs_ahead_is_said_so() {
5248        use sioul_core::dose_reminders::{DoseReminders, others};
5249        let due: i64 = 1_791_360_000; // 08:00
5250        let key = format!("levo@{due}");
5251        let desk = |started: i64, closed: i64, working: bool, exported: i64| sioul_sync::devices::Entry { id: "desk-id".into(), name: "desk".into(), kind: sioul_sync::devices::COMPUTER.into(), started, closed, working, exported, imported: exported, doses: true, ..sioul_sync::devices::Entry::default() };
5252        let none = sioul_sync::share::Heard::default();
5253        let mut kept = Peers::default();
5254        // Last night's: open at 22:00, closed at 23:00, read at 23:06.
5255        learn(&mut kept, &[], &[desk(due - 36_000, due - 32_400, false, due - 32_410)], &none, due - 32_000);
5256        // This morning its clock runs an hour ahead: opened at 07:00 (08:00 by its clock), its claim renewed at 07:59 (08:59).
5257        let claim = sioul_sync::lease::Claim { computer: "desk-id".into(), name: "desk".into(), since: due, renewed: due + 3_540, until: due + 3_840, active: due + 3_540, taken: 0, wrote: None, closed: false, pad: String::new() };
5258        learn(&mut kept, std::slice::from_ref(&claim), &[desk(due, 0, true, due + 3_540)], &none, due - 40);
5259        let peer = kept.peers["desk-id"].peer.clone();
5260        assert!(peer.set_aside && peer.said.as_ref().is_some_and(|s| s.started == due - 36_000), "set aside, last night's kept: never an older Sioul");
5261        let english = Translator::new("en");
5262        let french = Translator::new("fr");
5263        let words = "desk has its clock more than ten minutes ahead of this one's, so what it says is set aside here";
5264        let Others::Doubt(e) = others(&key, due, due + 600, "phone-id", true, Some(&DoseReminders::default()), std::slice::from_ref(&peer), true) else { panic!("never plain") };
5265        assert_eq!(elsewhere_said(&e, due, &english), words);
5266        assert_eq!(elsewhere_said(&e, due, &french), "desk a son horloge en avance de plus de dix minutes sur celle de cet appareil, et ce qu’il dit est mis de côté ici");
5267        let doubts = sioul_core::health::doubts_now(due, due + 600, None, std::slice::from_ref(&peer));
5268        assert_eq!(why_in(&doubts, &english), words);
5269        assert!(!why_in(&doubts, &french).contains("plus ancien"));
5270        // The alarm's notice: the reason said once.
5271        let knows = Knowledge { record_lost: None, peers: vec![peer.clone()] };
5272        let first = elsewhere_said(&e, due, tr());
5273        assert_eq!(doubt_unread_with(&knows, due, due, due + 600, None, Some(&first)), say("dose-doubt", &[("why", first.clone())]));
5274        // The phone's word stays on last night's opening: the desk, opened anew, is never the first.
5275        assert_eq!(peer.said.as_ref().map(|s| s.started.max(peer.latest_opening)), Some(due - 36_000));
5276    }
5277
5278    /// The re-check before 0.0.6 (LOW-2): the residual the final round left,
5279    /// composed as the tick reads it (`devices::chosen` of the folder's copy
5280    /// and the one fetched, `learn`, `newest_of`). The folder stops at the
5281    /// phone's 06:30 copy, its word on the desk in it; Sioul's own pull stays
5282    /// current. At 06:35 the phone's clock is set back three hours, and at
5283    /// 07:05 "This device is off" is said there of the desk. From the phone's
5284    /// next copy (07:10, dated 04:10) the word is withdrawn, though the
5285    /// folder's copy, dated later, still names the desk: at 08:00 the desk is
5286    /// not the first, where it reminded plainly (with the phone: two plain).
5287    #[test]
5288    fn a_word_the_lagging_copy_still_names_is_withdrawn() {
5289        use sioul_core::dose_reminders::QUIET_AFTER;
5290        let due: i64 = 1_791_360_000; // 08:00
5291        let opening = due - 82_800; // the desk opened yesterday at 09:00
5292        let none = sioul_sync::share::Heard::default();
5293        let copy = |dated: i64, word: bool| sioul_sync::devices::Entry {
5294            id: "phone-id".into(),
5295            kind: sioul_sync::devices::PHONE.into(),
5296            started: due - 10_800,
5297            closed: due - 10_700,
5298            exported: dated,
5299            imported: dated,
5300            doses: true,
5301            acked: if word { [("desk-id".to_string(), opening)].into_iter().collect() } else { std::collections::BTreeMap::new() },
5302            ..sioul_sync::devices::Entry::default()
5303        };
5304        let folder = copy(due - 5_400, true);
5305        let mut kept = Peers::default();
5306        learn(&mut kept, &[], &[sioul_sync::devices::chosen(folder.clone(), folder.clone())], &none, due - 5_340);
5307        let mut word_until = 0;
5308        // Every ten minutes from 06:40 a copy, dated three hours behind; the word taken back at 07:05.
5309        for real in (due - 4_800..due).step_by(600) {
5310            let fetched = copy(real - 10_800, real < due - 3_300);
5311            let read = sioul_sync::devices::chosen(folder.clone(), fetched);
5312            assert_eq!(read.exported, due - 5_400, "the folder's copy, dated later, is the one read");
5313            learn(&mut kept, &[], &[read], &none, real + 60);
5314            if acknowledgements("desk-id", &newest_of(&kept)).get("phone-id") == Some(&opening) {
5315                word_until = real + 60;
5316            }
5317        }
5318        assert_eq!(word_until, due - 3_540, "the word stood until 07:01, gone from 07:11");
5319        let knows = Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
5320        let at = due - 30;
5321        let acks = acknowledgements("desk-id", &newest_of(&kept));
5322        let acked = acked_after(Some((opening, opening + 180)), all_acknowledged(&knows, opening, &acks, at), opening, at);
5323        let first = first_here(false, true, opening, acked.map(|(_, at)| at), due, word_lead(&knows));
5324        assert!(!first);
5325        assert_eq!(plan(None, true, first, due, due + 10, true), Plan::Quiet(due + QUIET_AFTER), "no plain reminder at 08:00");
5326    }
5327
5328    /// The re-check before 0.0.6 (LOW-2): a new word stands while one channel
5329    /// lags. The desk opened anew at 07:00; the phone names that opening from
5330    /// 07:02, in every copy Sioul's own pull brings; the folder stays on the
5331    /// phone's 06:30 copy, which names the desk's opening before: only behind,
5332    /// it takes nothing away, and the desk reminds plainly at 08:00. The cost:
5333    /// had the lagging copy named the desk not at all (counted again, paired
5334    /// anew), the word would wait for it.
5335    #[test]
5336    fn a_new_word_stands_while_one_channel_lags() {
5337        let due: i64 = 1_791_360_000; // 08:00
5338        let (before, opening) = (due - 82_800, due - 3_600); // yesterday 09:00; today 07:00
5339        let none = sioul_sync::share::Heard::default();
5340        let copy = |dated: i64, word: Option<i64>| sioul_sync::devices::Entry {
5341            id: "phone-id".into(),
5342            kind: sioul_sync::devices::PHONE.into(),
5343            started: due - 10_800,
5344            closed: due - 10_700,
5345            exported: dated,
5346            imported: dated,
5347            doses: true,
5348            acked: word.map(|at| ("desk-id".to_string(), at)).into_iter().collect(),
5349            ..sioul_sync::devices::Entry::default()
5350        };
5351        let run = |folder: &sioul_sync::devices::Entry| {
5352            let mut kept = Peers::default();
5353            let mut acked = None;
5354            for real in (due - 3_480..due).step_by(600) {
5355                learn(&mut kept, &[], &[sioul_sync::devices::chosen(folder.clone(), copy(real, Some(opening)))], &none, real + 60);
5356                let knows = Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
5357                acked = acked_after(acked, all_acknowledged(&knows, opening, &acknowledgements("desk-id", &newest_of(&kept)), real + 60), opening, real + 60);
5358            }
5359            let knows = Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
5360            (acked, first_here(false, true, opening, acked.map(|(_, at)| at), due, word_lead(&knows)))
5361        };
5362        let (acked, first) = run(&copy(due - 5_400, Some(before)));
5363        assert_eq!(acked, Some((opening, due - 3_420)), "the word from 07:03");
5364        assert!(first);
5365        assert_eq!(plan(None, true, first, due, due + 10, true), Plan::First, "plain at 08:00, as before");
5366        // The cost: a lagging copy that names the desk not at all holds the word back until it catches up.
5367        let (acked, first) = run(&copy(due - 5_400, None));
5368        assert!(acked.is_none() && !first);
5369    }
5370
5371    /// At waking (doses kept for it), the desk's first reminder counts its
5372    /// doubt from waking, not from the dose's time (the fifth review's M2):
5373    /// a phone put away before the sleep may be picked up at waking to mark
5374    /// it, its alarm having waited for waking too; known only once the news
5375    /// of that opening had the time to come, as at a dose's time.
5376    #[test]
5377    fn at_waking_the_first_reminder_counts_from_waking() {
5378        use sioul_core::health::Said;
5379        let (bed, woke) = (1_800_000_000, 1_800_028_800);
5380        let due = bed + 6 * 3600;
5381        let since = news_from(due, Some((bed, woke)));
5382        assert_eq!(since, woke);
5383        let phone = Peer {
5384            name: "phone".into(),
5385            id: "phone-id".into(),
5386            said: Some(Said { started: bed - 7_200, closed: bed - 1_800, working: false, exported: bed - 1_800, imported: bed - 1_800, doses: true, left: false, phone: true }),
5387            complete: true,
5388            seen: bed - 1_800,
5389            heard: bed - 1_800,
5390            wake: Some(30),
5391            ..Peer::default()
5392        };
5393        let knowledge = Knowledge { record_lost: None, peers: vec![phone] };
5394        assert!(doubt_of(&knowledge, due, due, woke + 30).is_empty(), "counted from the dose's time, the phone would be known by its alarm long rung");
5395        assert!(!doubt_of(&knowledge, due, since, woke + 30).is_empty(), "counted from waking: the phone may have been picked up to mark it");
5396        assert!(doubt_of(&knowledge, due, since, woke + 30 + sioul_core::health::SKEW).is_empty(), "known once the news of an opening had the time to come");
5397        assert_eq!(plan(None, true, true, since, woke + 30, true), Plan::First, "its first, at waking");
5398    }
5399
5400    /// The question on the past for a dose whose reminder may have shown
5401    /// (`maybe`): its own question, "may not have been shown", its own title,
5402    /// in English and French; mixed with others, a title true of all.
5403    #[test]
5404    fn a_reminder_that_may_have_shown_is_asked_about_under_its_own_question() {
5405        let health = Health {
5406            medicines: vec![
5407                Medicine { id: "demo-a".into(), name: "Demo A".into(), dose: String::new(), schedule: Schedule::Day { times: vec!["08:00".into()], amounts: std::collections::BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None },
5408                Medicine { id: "demo-b".into(), name: "Demo B".into(), dose: String::new(), schedule: Schedule::Day { times: vec!["07:00".into()], amounts: std::collections::BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None },
5409            ],
5410            ..Health::default()
5411        };
5412        let (english, french) = (Translator::new("en"), Translator::new("fr"));
5413        let at = |text: &str| text.parse::<Zoned>().unwrap();
5414        let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
5415        let (a, b) = (key("demo-a", "2026-10-06T08:00[Europe/Paris]"), key("demo-b", "2026-10-06T07:00[Europe/Paris]"));
5416        let alone = Knowledge { record_lost: None, peers: Vec::new() };
5417        let noon = at("2026-10-06T12:00[Europe/Paris]");
5418        let mut state = HealthState::default();
5419        let sent = at("2026-10-06T08:00:02[Europe/Paris]").timestamp().as_second();
5420        note_minute(&mut state, &[], &[], std::slice::from_ref(&a), &[], sent);
5421        // The desk's notice went (recorded before it went), not known shown.
5422        let mut told = DoseReminders::default();
5423        told.entry(&a, "desk-id").maybe = sent;
5424        let rows = missed_rows(&health, &state, &told, &alone, &noon, &english);
5425        assert_eq!(rows.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
5426        assert!(rows[1].question.starts_with("Due while Sioul ran, but their reminder may not have been shown"), "{}", rows[1].question);
5427        assert!(missed_rows(&health, &state, &told, &alone, &noon, &french)[1].question.starts_with("Prévus pendant que Sioul tournait, mais leur rappel ne s’est peut-être pas affiché"));
5428        let missed = state.unanswered(&health, &noon, MISSED_HOURS, GRACE_MINUTES);
5429        assert_eq!(missed_title(&missed[1..], &state, &told, &english), "A reminder may not have shown");
5430        assert_eq!(missed_title(&missed[1..], &state, &told, &french), "Un rappel ne s’est peut-être pas affiché");
5431        assert_eq!(missed_title(&missed, &state, &told, &english), "Doses due earlier");
5432        // Without the desk's word: not shown, as it reads here.
5433        assert_eq!(missed_title(&missed[1..], &state, &DoseReminders::default(), &english), "A reminder could not be shown");
5434    }
5435
5436    /// A dose is open, for a phone's steps (`steps::keeps_close`), from its
5437    /// time until it is marked (here, or in the records come from another
5438    /// device) or its day ends: while one is and another device runs, the
5439    /// phone keeps its two-minute steps, at night too (review of battery part
5440    /// C, F2). A record that does not read counts the dose open in the same
5441    /// hours alone, marked or not: never before its time, never past its
5442    /// day's end (G4).
5443    #[test]
5444    fn a_dose_is_open_from_its_time_until_marked_or_its_day_ends() {
5445        let dir = std::env::temp_dir().join(format!("sioul-dose-open-{}", std::process::id()));
5446        let _ = std::fs::remove_dir_all(&dir);
5447        std::fs::create_dir_all(&dir).unwrap();
5448        let path = dir.join("health.toml");
5449        std::fs::write(&path, "[[medicine]]\nid = \"mag\"\nname = \"Magnesium\"\ndose = \"300 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"22:00\"]\n").unwrap();
5450        let health = Health::load(&path);
5451        let at = |text: &str| text.parse::<Zoned>().unwrap();
5452        let s = |text: &str| at(text).timestamp().as_second();
5453        let (state, none) = (HealthState::default(), DoseRecords::default());
5454        let read = Some((&state, &none));
5455        assert!(!dose_open(&health, read, &at("2026-10-06T21:59[Europe/Paris]")), "not its time yet");
5456        assert!(dose_open(&health, read, &at("2026-10-06T22:00[Europe/Paris]")), "its time: open");
5457        assert!(dose_open(&health, read, &at("2026-10-06T23:30[Europe/Paris]")), "not marked, the night begun");
5458        assert!(!dose_open(&health, read, &at("2026-10-07T00:30[Europe/Paris]")), "its day over: no longer asked about");
5459        // Marked here; or at the desk, its record come before its mark: closed.
5460        let key = format!("mag@{}", s("2026-10-06T22:00[Europe/Paris]"));
5461        let mut taken = HealthState::default();
5462        taken.taken.insert(key.clone(), s("2026-10-06T22:05[Europe/Paris]"));
5463        assert!(!dose_open(&health, Some((&taken, &none)), &at("2026-10-06T22:10[Europe/Paris]")));
5464        let mut records = DoseRecords::default();
5465        let marked = s("2026-10-06T22:07[Europe/Paris]");
5466        records.answer(&key, "desk", Answer { state: sioul_core::doses::TAKEN.into(), at: marked, noted: marked, name: "desk".into(), kind: "computer".into() });
5467        assert!(!dose_open(&health, Some((&state, &records)), &at("2026-10-06T22:10[Europe/Paris]")), "marked at the desk");
5468        assert!(!dose_open(&Health::default(), read, &at("2026-10-06T22:10[Europe/Paris]")), "no medicine");
5469        // A record that does not read: open from the dose's time to its day's end, marked or not; never outside.
5470        assert!(!dose_open(&health, None, &at("2026-10-06T20:00[Europe/Paris]")), "unread, its time two hours on: not open");
5471        assert!(!dose_open(&health, None, &at("2026-10-06T21:59[Europe/Paris]")), "unread, not its time yet");
5472        assert!(dose_open(&health, None, &at("2026-10-06T22:00[Europe/Paris]")), "unread, its time: open (the safe side)");
5473        assert!(dose_open(&health, None, &at("2026-10-06T23:59[Europe/Paris]")), "unread, the night begun");
5474        assert!(!dose_open(&health, None, &at("2026-10-07T00:30[Europe/Paris]")), "unread, its day over: closed");
5475        assert!(!dose_open(&health, None, &at("2026-10-07T12:00[Europe/Paris]")), "unread, the next day before its time");
5476        assert!(!dose_open(&Health::default(), None, &at("2026-10-06T22:10[Europe/Paris]")), "unread, no medicine");
5477        let _ = std::fs::remove_dir_all(&dir);
5478    }
5479
5480    /// The phone's home screen card (`homecard`), on a health file written
5481    /// for the test: today's doses due and not marked, and those to come
5482    /// today; each known not marked only while the other devices were heard
5483    /// within five minutes; a record that does not read, never as empty.
5484    #[test]
5485    fn the_home_screen_card_says_doses_as_known() {
5486        let dir = std::env::temp_dir().join(format!("sioul-card-doses-{}", std::process::id()));
5487        let _ = std::fs::remove_dir_all(&dir);
5488        std::fs::create_dir_all(&dir).unwrap();
5489        let path = dir.join("health.toml");
5490        std::fs::write(&path, "[[medicine]]\nid = \"mag\"\nname = \"Magnesium\"\ndose = \"300 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"12:30\", \"20:00\"]\n").unwrap();
5491        let health = Health::load(&path);
5492        let at = |text: &str| text.parse::<Zoned>().unwrap();
5493        let s = |text: &str| at(text).timestamp().as_second();
5494        let now = at("2026-10-06T13:00[Europe/Paris]");
5495        let alone = Knowledge { record_lost: None, peers: Vec::new() };
5496        let mut state = HealthState::default();
5497        let doses = doses_for_card(&health, &state, &DoseRecords::default(), true, &alone, &now);
5498        // 12:30 due and not marked; 20:00 to come; tomorrow's not given.
5499        assert_eq!(doses.iter().map(|d| (d.name.as_str(), d.time.as_str())).collect::<Vec<_>>(), [("Magnesium · 300 mg", "12:30"), ("Magnesium · 300 mg", "20:00")]);
5500        // This device alone: known until the card lets it go, at midnight.
5501        let midnight = s("2026-10-07T00:00[Europe/Paris]");
5502        assert_eq!((doses[0].at, doses[0].until, doses[0].known_until), (s("2026-10-06T12:30[Europe/Paris]"), midnight, midnight));
5503        // Marked: gone.
5504        state.taken.insert(format!("mag@{}", s("2026-10-06T12:30[Europe/Paris]")), s("2026-10-06T12:35[Europe/Paris]"));
5505        assert_eq!(doses_for_card(&health, &state, &DoseRecords::default(), true, &alone, &now).len(), 1);
5506        // The laptop heard in full at 12:58: known until five minutes after, then not.
5507        let heard = s("2026-10-06T12:58[Europe/Paris]");
5508        let laptop = Knowledge { record_lost: None, peers: vec![Peer { name: "laptop".into(), known_until: heard, closed: false, delay: Some(30), broken: None, heard, ..Peer::default() }] };
5509        let fresh = doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), true, &laptop, &now);
5510        assert_eq!(fresh[0].known_until, heard + 5 * 60 + 1);
5511        // 20:00, still to come: what the laptop marks by then is not here now; not known from its time on.
5512        assert_eq!(fresh[1].known_until, 0);
5513        // Heard before the dose was due: not known at all.
5514        let noon = s("2026-10-06T12:00[Europe/Paris]");
5515        let old = Knowledge { record_lost: None, peers: vec![Peer { name: "laptop".into(), known_until: noon, closed: false, delay: Some(30), broken: None, heard: noon, ..Peer::default() }] };
5516        assert_eq!(doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), true, &old, &now)[0].known_until, 0);
5517        // A record that does not read: every dose not known, none left out.
5518        let unread = doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), false, &alone, &now);
5519        assert_eq!(unread.len(), 2);
5520        assert!(unread.iter().all(|d| d.known_until == 0));
5521        // Taken here, said not taken on another device: answers that differ, on the card all the
5522        // same, never known (the Porch asks to choose); never left out as if marked.
5523        let mut differ = HealthState::default();
5524        let key = format!("mag@{}", s("2026-10-06T12:30[Europe/Paris]"));
5525        differ.taken.insert(key.clone(), s("2026-10-06T12:35[Europe/Paris]"));
5526        differ.not_taken.insert(key, s("2026-10-06T12:40[Europe/Paris]"));
5527        let doses = doses_for_card(&health, &differ, &DoseRecords::default(), true, &alone, &now);
5528        assert_eq!(doses.iter().map(|d| (d.time.as_str(), d.known_until)).collect::<Vec<_>>(), [("12:30", 0), ("20:00", midnight)]);
5529        // Answered in the records only, their marks not here yet: the answer counts, and a dose
5530        // answered differently there is on the card once, never known.
5531        let key = format!("mag@{}", s("2026-10-06T12:30[Europe/Paris]"));
5532        let given = |state: &str, at: &str, name: &str| Answer { state: state.into(), at: s(at), noted: s(at), name: name.into(), kind: name.into() };
5533        let mut records = DoseRecords::default();
5534        records.answer(&key, "phone-id", given(sioul_core::doses::TAKEN, "2026-10-06T12:35[Europe/Paris]", "phone"));
5535        let doses = doses_for_card(&health, &HealthState::default(), &records, true, &alone, &now);
5536        assert_eq!(doses.iter().map(|d| d.time.as_str()).collect::<Vec<_>>(), ["20:00"]);
5537        records.answer(&key, "desk-id", given(sioul_core::doses::SKIPPED, "2026-10-06T12:40[Europe/Paris]", "computer"));
5538        let doses = doses_for_card(&health, &HealthState::default(), &records, true, &alone, &now);
5539        assert_eq!(doses.iter().map(|d| (d.time.as_str(), d.known_until)).collect::<Vec<_>>(), [("12:30", 0), ("20:00", midnight)]);
5540        let _ = std::fs::remove_dir_all(&dir);
5541    }
5542
5543    /// F21 on the phone's home screen card: a dose to come, a computer closed
5544    /// before it whose opening's news comes within two minutes: "check" from
5545    /// its time for those two minutes and `SKEW` more, then said as known; a dose due an hour
5546    /// ago, known at once. The card's Java shows each piece in its own time.
5547    #[test]
5548    fn the_card_says_check_while_a_closed_device_may_have_opened() {
5549        let dir = std::env::temp_dir().join(format!("sioul-card-wake-{}", std::process::id()));
5550        let _ = std::fs::remove_dir_all(&dir);
5551        std::fs::create_dir_all(&dir).unwrap();
5552        let path = dir.join("health.toml");
5553        std::fs::write(&path, "[[medicine]]\nid = \"mag\"\nname = \"Magnesium\"\ndose = \"300 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"12:30\", \"20:00\"]\n").unwrap();
5554        let health = Health::load(&path);
5555        let s = |text: &str| text.parse::<Zoned>().unwrap().timestamp().as_second();
5556        let now = "2026-10-06T13:00[Europe/Paris]".parse::<Zoned>().unwrap();
5557        let said = sioul_core::health::Said { started: s("2026-10-06T08:00[Europe/Paris]"), closed: s("2026-10-06T12:00[Europe/Paris]"), working: false, exported: s("2026-10-06T12:00[Europe/Paris]"), doses: true, ..Default::default() };
5558        let desk = Peer { id: "desk-id".into(), name: "desk".into(), said: Some(said), complete: true, seen: s("2026-10-06T12:01[Europe/Paris]"), heard: s("2026-10-06T12:00[Europe/Paris]"), wake: Some(120), ..Peer::default() };
5559        let knowledge = Knowledge { record_lost: None, peers: vec![desk] };
5560        let doses = doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), true, &knowledge, &now);
5561        let (eight, midnight) = (s("2026-10-06T20:00[Europe/Paris]"), s("2026-10-07T00:00[Europe/Paris]"));
5562        let pieces: Vec<(&str, i64, i64, i64)> = doses.iter().map(|d| (d.time.as_str(), d.at, d.until, d.known_until)).collect();
5563        let woke = eight + 120 + sioul_core::health::SKEW;
5564        assert_eq!(pieces, [("12:30", s("2026-10-06T12:30[Europe/Paris]"), midnight, midnight), ("20:00", eight, woke, 0), ("20:00", woke, midnight, midnight)]);
5565        let _ = std::fs::remove_dir_all(&dir);
5566    }
5567
5568    fn heard_of(id: &str, n: u64) -> sioul_sync::share::Heard {
5569        sioul_sync::share::Heard { read: [(id.to_string(), (1, n))].into(), broken: Default::default() }
5570    }
5571
5572    /// The devices' registry, learned (`learn`): each entry seen changing here
5573    /// is timed by this device's clock; an export dated later than it was seen
5574    /// tells how far the other clock is ahead, at least; what no longer reads
5575    /// is not said; a device said off counts again once it says anything newer.
5576    /// An alarm that read none of the others' news since it went off
5577    /// (`share::news_missed`): the laptop, known closed before the dose by
5578    /// what was read before, is said in doubt with when it closed; with news
5579    /// read, it is known, as before (docs/health.md, "No news at the alarm").
5580    #[test]
5581    fn an_alarm_that_read_no_news_says_a_closed_device_in_doubt() {
5582        let english = Translator::new("en");
5583        let due = 1_800_000_000;
5584        let said = sioul_core::health::Said { started: due - 7_200, closed: due - 3_600, working: false, exported: due - 3_605, imported: due - 3_605, doses: true, ..Default::default() };
5585        let laptop = Peer { id: "laptop-id".into(), name: "laptop".into(), said: Some(said), complete: true, seen: due - 3_500, heard: due - 3_605, ..Peer::default() };
5586        let peers = vec![laptop];
5587        assert!(sioul_core::health::doubts(due, due + 60, None, &peers).is_empty(), "news read: known");
5588        let doubts = sioul_core::health::doubts_unread(due, due + 60, None, &peers, due + 30, &Default::default());
5589        let words = why_in(&doubts, &english);
5590        assert!(words.starts_with("laptop closed") && words.contains("its news can be slow to come") && !words.to_lowercase().contains("not taken"), "{words}");
5591    }
5592
5593    /// F21 of the review of 5 October 2026: how late the news of a device's
5594    /// opening comes here, timed on its starts seen while this device looked
5595    /// a moment before (its own absence never counts), its clock's lead taken
5596    /// off; the longest of the last ones is the wait for a device closed
5597    /// before a dose (`sioul_core::health::doubts_now`).
5598    #[test]
5599    fn how_late_a_devices_opening_comes_is_timed_while_looking() {
5600        use sioul_sync::devices::Entry;
5601        let phone = |started: i64, working: bool, closed: i64| Entry { id: "phone-id".into(), name: "FP3".into(), kind: "phone".into(), started, closed, working, exported: started.max(closed), wrote: Some((1, 5)), doses: true, ..Entry::default() };
5602        let mut kept = Peers::default();
5603        // Closed at 1 000; seen first at 1 020: nothing timed.
5604        learn(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), 1_020);
5605        for now in [1_100, 1_200, 1_300] {
5606            learn(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), now);
5607        }
5608        assert_eq!(kept.peers["phone-id"].peer.wake, None);
5609        // 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.
5610        learn(&mut kept, &[], &[phone(1_150, true, 1_000)], &heard_of("phone-id", 5), 1_390);
5611        assert_eq!(kept.peers["phone-id"].peer.wake, Some(240));
5612        // Closed again, then opened while this device was away for ten minutes: its absence not counted.
5613        learn(&mut kept, &[], &[phone(1_150, false, 1_500)], &heard_of("phone-id", 5), 1_510);
5614        learn(&mut kept, &[], &[phone(1_600, true, 1_500)], &heard_of("phone-id", 5), 2_200);
5615        assert_eq!(kept.peers["phone-id"].peer.wake, Some(240));
5616        // A quicker one: the longest of the last ones stays the wait.
5617        learn(&mut kept, &[], &[phone(1_600, false, 2_250)], &heard_of("phone-id", 5), 2_260);
5618        learn(&mut kept, &[], &[phone(2_300, true, 2_250)], &heard_of("phone-id", 5), 2_330);
5619        assert_eq!(kept.peers["phone-id"].peer.wake, Some(240));
5620        // A dose due at 3 000, the phone closed at 2 900: in doubt for those 240 s and `SKEW`, then known.
5621        learn(&mut kept, &[], &[phone(2_300, false, 2_900)], &heard_of("phone-id", 5), 2_910);
5622        let peers = vec![kept.peers["phone-id"].peer.clone()];
5623        let skew = sioul_core::health::SKEW;
5624        assert_eq!(sioul_core::health::doubts_now(3_000, 3_239 + skew, None, &peers).len(), 1);
5625        assert!(sioul_core::health::doubts_now(3_000, 3_240 + skew, None, &peers).is_empty());
5626    }
5627
5628    /// R9 of the review of 10 October 2026: a dose's alarm in doubt asks again
5629    /// when the doubt may end, a minute apart at least; in doubt only for the
5630    /// news missed since it began, at the end of the wait for news, not each
5631    /// minute.
5632    #[test]
5633    fn an_alarm_in_doubt_asks_again_when_the_doubt_may_end() {
5634        let due = 1_800_000_000;
5635        assert_eq!(alarm_again(due, due + 5, Some(due + 300)), due + 300, "a closed device's opening could have come by then");
5636        assert_eq!(alarm_again(due, due + 5, Some(due + 30)), due + 65, "a minute apart at least");
5637        assert_eq!(alarm_again(due, due + 5, Some(due + 6)), due + WAIT_FOR_NEWS, "known by what was read: only the news missed");
5638        assert_eq!(alarm_again(due, due + 5, None), due + WAIT_FOR_NEWS);
5639        assert_eq!(alarm_again(due, due + 5, Some(due + 2 * WAIT_FOR_NEWS)), due + WAIT_FOR_NEWS);
5640    }
5641
5642    /// A dose due while sleep held it (doses kept for waking): the phone's
5643    /// alarm, asked again at waking, waits for a computer's news from waking,
5644    /// as from a dose's time, since that computer reminds it at waking too;
5645    /// the two never remind it together, both plainly (review of battery part
5646    /// C, the fourth round). The computer's late tries count from waking too
5647    /// (until `LATE_UNTIL` after it), and the phone knows them read only once
5648    /// the computer's records from past then are. A dose due after waking,
5649    /// or before the sleep began, counts from its own time; asking again in
5650    /// doubt ends there too.
5651    #[test]
5652    fn a_phone_held_by_sleep_waits_for_news_from_waking() {
5653        use sioul_core::health::Said;
5654        let (bed, woke) = (1_800_000_000, 1_800_028_800);
5655        let due = bed + 5 * 3600;
5656        assert_eq!(news_from(due, Some((bed, woke))), woke, "due asleep: from waking");
5657        assert_eq!(news_from(woke + 600, Some((bed, woke))), woke + 600, "due after waking: from its time");
5658        assert_eq!(news_from(bed - 600, Some((bed, woke))), bed - 600, "due before the sleep: from its time");
5659        assert_eq!(news_from(due, None), due, "no sleep held it");
5660        let since = news_from(due, Some((bed, woke)));
5661        assert_eq!(alarm_again(since, woke + 5, None), woke + WAIT_FOR_NEWS, "asked again at the end of the wait from waking");
5662        // The desk's tries count from waking too: quiet around the phone's alarm, promised; the
5663        // phone, reading that promise, is the first, plainly; without it, the doubt.
5664        let unshown = Some(Tried { unshown: woke + 2, ..Tried::default() });
5665        assert_eq!(plan(unshown, true, true, since, woke + LATE_UNTIL + 30, true), Plan::Quiet(woke + sioul_core::dose_reminders::QUIET_AFTER));
5666        let key = format!("levo@{due}");
5667        let mut told = DoseReminders::default();
5668        *told.entry(&key, "desk-id") = Tried { unshown: woke + 2, quiet: woke + sioul_core::dose_reminders::QUIET_AFTER, ..Tried::default() };
5669        let desk = Peer {
5670            name: "desk".into(),
5671            id: "desk-id".into(),
5672            said: Some(Said { started: bed - 3_600, closed: 0, working: true, exported: woke + 540, imported: woke + 540, doses: true, left: false, phone: false }),
5673            complete: true,
5674            seen: woke + 540,
5675            heard: woke + 540,
5676            ..Peer::default()
5677        };
5678        let at_alarm = woke + WAIT_FOR_NEWS;
5679        assert_eq!(sioul_core::dose_reminders::others(&key, since, at_alarm, "phone-id", true, Some(&told), std::slice::from_ref(&desk), true), Others::First);
5680        assert!(matches!(sioul_core::dose_reminders::others(&key, due, at_alarm, "phone-id", true, Some(&told), std::slice::from_ref(&desk), true), Others::First), "the promise, not the dose's time, says it");
5681        let unpromised = { let mut told = told.clone(); told.entry(&key, "desk-id").quiet = 0; told };
5682        assert!(matches!(sioul_core::dose_reminders::others(&key, since, at_alarm, "phone-id", true, Some(&unpromised), std::slice::from_ref(&desk), true), Others::Doubt(_)));
5683    }
5684
5685    /// R8 of the review of 10 October 2026: an opening is timed only while
5686    /// this device's own news flows. Its own pull stale (a desktop whose
5687    /// pulls failed for hours): the delay is its outage, not timed. A sync
5688    /// app alone carrying the folder (nothing tells here whether it paused):
5689    /// timed up to an hour, never more.
5690    #[test]
5691    fn an_opening_is_not_timed_by_this_devices_own_outage() {
5692        use sioul_sync::devices::Entry;
5693        let phone = |started: i64, working: bool, closed: i64| Entry { id: "phone-id".into(), name: "FP3".into(), kind: "phone".into(), started, closed, working, exported: started.max(closed), wrote: Some((1, 5)), doses: true, ..Entry::default() };
5694        let opened_late = |news: Option<bool>| {
5695            let mut kept = Peers::default();
5696            learn_with(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), news, 1_020);
5697            learn_with(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), news, 1_080);
5698            // Opened at 1 100, its news seen here five hours later, this device looking each minute.
5699            for now in (1_140..19_100).step_by(60) {
5700                learn_with(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), news, now);
5701            }
5702            learn_with(&mut kept, &[], &[phone(1_100, true, 1_000)], &heard_of("phone-id", 5), news, 19_100);
5703            kept.peers["phone-id"].peer.wake
5704        };
5705        assert_eq!(opened_late(Some(false)), None, "its own pull stale: its outage, not the phone's");
5706        assert_eq!(opened_late(Some(true)), Some(18_000), "its own pull went through: the phone's news was late");
5707        assert_eq!(opened_late(None), Some(UNSURE_WAKE), "a sync app alone: an hour at most");
5708    }
5709
5710    #[test]
5711    fn what_the_devices_say_of_themselves_teaches() {
5712        use sioul_sync::devices::Entry;
5713        let phone = |exported: i64, working: bool, closed: i64| Entry { id: "phone-id".into(), name: "FP3".into(), kind: "phone".into(), started: 1_000, closed, working, exported, wrote: Some((1, 5)), doses: true, ..Entry::default() };
5714        let mut kept = Peers::default();
5715        learn(&mut kept, &[], &[phone(1_100, true, 0)], &heard_of("phone-id", 5), 1_120);
5716        let peer = &kept.peers["phone-id"].peer;
5717        assert_eq!((peer.seen, peer.complete, peer.said.as_ref().map(|s| (s.working, s.phone))), (1_120, true, Some((true, true))));
5718        // The same entry a minute later: not seen changing.
5719        learn(&mut kept, &[], &[phone(1_100, true, 0)], &heard_of("phone-id", 5), 1_180);
5720        assert_eq!(kept.peers["phone-id"].peer.seen, 1_120);
5721        // An export dated three minutes after it was seen here: its clock three minutes ahead, at least.
5722        learn(&mut kept, &[], &[phone(1_400, true, 0)], &heard_of("phone-id", 5), 1_220);
5723        assert_eq!((kept.peers["phone-id"].peer.seen, kept.peers["phone-id"].peer.ahead), (1_220, 180));
5724        // Its records not all read here: not complete.
5725        learn(&mut kept, &[], &[Entry { wrote: Some((1, 9)), ..phone(1_460, true, 0) }], &heard_of("phone-id", 5), 1_280);
5726        assert!(!kept.peers["phone-id"].peer.complete);
5727        // Its entry no longer reads: nothing said of it, never as it last said.
5728        learn(&mut kept, &[], &[], &heard_of("phone-id", 9), 1_340);
5729        assert_eq!(kept.peers["phone-id"].peer.said, None);
5730        // Said off: not counted; counted again once it says anything newer.
5731        learn(&mut kept, &[], &[phone(1_460, true, 0)], &heard_of("phone-id", 9), 1_400);
5732        kept.peers.get_mut("phone-id").unwrap().off = Some(Mark { started: 1_000, exported: 1_460, renewed: 0, at: 1_400 });
5733        learn(&mut kept, &[], &[phone(1_460, true, 0)], &heard_of("phone-id", 9), 1_460);
5734        assert!(kept.peers["phone-id"].peer.off, "nothing newer: still off");
5735        learn(&mut kept, &[], &[phone(1_520, false, 1_525)], &heard_of("phone-id", 9), 1_530);
5736        assert!(!kept.peers["phone-id"].peer.off, "it spoke again");
5737        // Closed at 1 525 by its entry, but its claim renewed open at 1 700 (an older Sioul put back
5738        // there, which updates no entry): the entry is not trusted, its claims are, as an older Sioul's.
5739        let open = Claim { computer: "phone-id".into(), name: "localhost".into(), since: 1_600, renewed: 1_700, until: 2_000, active: 1_700, taken: 0, wrote: Some((1, 9)), closed: false, pad: String::new() };
5740        learn(&mut kept, &[open], &[phone(1_520, false, 1_525)], &heard_of("phone-id", 9), 1_710);
5741        assert_eq!(kept.peers["phone-id"].peer.said, None);
5742    }
5743
5744    /// The Porch's doses with the devices' registry, on a health file written
5745    /// for the test: a dose your devices answered differently shows both
5746    /// answers, the earliest first, and lets you choose; one the phone may
5747    /// hold (in use, silent since the dose) names it and offers "This device
5748    /// is off"; the phone closed cleanly, nothing in doubt. Never "not taken".
5749    #[test]
5750    fn the_porch_says_answers_that_differ_and_which_device_may_know() {
5751        use sioul_core::health::Said;
5752        let dir = std::env::temp_dir().join(format!("sioul-porch-registry-{}", std::process::id()));
5753        let _ = std::fs::remove_dir_all(&dir);
5754        std::fs::create_dir_all(&dir).unwrap();
5755        let path = dir.join("health.toml");
5756        std::fs::write(&path, "[[medicine]]\nid = \"levo\"\nname = \"Levothyroxine\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"12:00\"]\n").unwrap();
5757        let health = Health::load(&path);
5758        let english = Translator::new("en");
5759        let s = |text: &str| text.parse::<Zoned>().unwrap().timestamp().as_second();
5760        let eight = format!("levo@{}", s("2026-10-06T08:00[Europe/Paris]"));
5761        let noon = "2026-10-06T12:00:40[Europe/Paris]".parse::<Zoned>().unwrap();
5762        // Taken on the phone at 08:02 (its sync slow); said not taken here at 08:10.
5763        let mut records = DoseRecords::default();
5764        records.answer(&eight, "phone-id", Answer { state: "taken".into(), at: s("2026-10-06T08:02[Europe/Paris]"), noted: s("2026-10-06T08:02[Europe/Paris]"), name: "FP3".into(), kind: "phone".into() });
5765        records.answer(&eight, "desk-id", Answer { state: "skipped".into(), at: s("2026-10-06T08:10[Europe/Paris]"), noted: s("2026-10-06T08:10[Europe/Paris]"), name: "desk".into(), kind: "computer".into() });
5766        let mut state = HealthState::default();
5767        state.taken.insert(eight.clone(), s("2026-10-06T08:02[Europe/Paris]"));
5768        state.not_taken.insert(eight.clone(), s("2026-10-06T08:10[Europe/Paris]"));
5769        let shown = |knowledge: &Knowledge| serde_json::to_value(porch_doses(&health, &state, &records, &DoseReminders::default(), "desk-id", knowledge, &noon, false, true, &english)).unwrap();
5770        let alone = Knowledge { record_lost: None, peers: Vec::new() };
5771        let view = shown(&alone);
5772        let due = view["due"].as_array().unwrap();
5773        assert_eq!(due.len(), 2, "{view}");
5774        let differ = due[0]["doubt"].as_str().unwrap();
5775        assert!(differ.starts_with("Marked taken on the phone ") && differ.contains(", skipped here ") && differ.ends_with("Check which is right before taking it."), "{differ}");
5776        assert_eq!((due[0]["key"].as_str(), due[0]["choose"].as_bool()), (Some(eight.as_str()), Some(true)));
5777        assert_eq!((due[1]["doubt"].as_str(), due[1]["choose"].as_bool()), (Some(""), Some(false)));
5778        // The phone in use, its last export at 11:45, before the 12:00 dose: named, and "This device is off" offered.
5779        let phone = Peer { id: "phone-id".into(), name: "FP3".into(), said: Some(Said { started: s("2026-10-06T07:00[Europe/Paris]"), working: true, exported: s("2026-10-06T11:45[Europe/Paris]"), doses: true, phone: true, ..Said::default() }), complete: true, seen: s("2026-10-06T11:45:30[Europe/Paris]"), heard: s("2026-10-06T11:45[Europe/Paris]"), ..Peer::default() };
5780        let view = shown(&Knowledge { record_lost: None, peers: vec![phone.clone()] });
5781        let row = &view["due"][1];
5782        let doubt = row["doubt"].as_str().unwrap();
5783        assert!(doubt.starts_with("Sioul can't tell whether it was taken: the phone was in use and last shared ") && doubt.ends_with(". Check before taking it."), "{doubt}");
5784        assert!(!doubt.to_lowercase().contains("not taken"), "{doubt}");
5785        assert_eq!(row["doubt_off"], serde_json::json!([{ "id": "phone-id", "label": "This device is off" }]));
5786        // Closed cleanly at 11:50, after its last export, read in full: known; nothing to say off.
5787        let closed = Peer { said: Some(Said { started: s("2026-10-06T07:00[Europe/Paris]"), closed: s("2026-10-06T11:50[Europe/Paris]"), exported: s("2026-10-06T11:49[Europe/Paris]"), doses: true, phone: true, ..Said::default() }), ..phone.clone() };
5788        let view = shown(&Knowledge { record_lost: None, peers: vec![closed] });
5789        assert_eq!((view["due"][1]["doubt"].as_str(), view["due"][1]["doubt_off"].as_array().map(Vec::len)), (Some(""), Some(0)));
5790        // You said it is off: not counted, the dose shown due, never answered for you.
5791        let off = Peer { off: true, ..phone };
5792        let view = shown(&Knowledge { record_lost: None, peers: vec![off] });
5793        assert_eq!((view["due"][1]["doubt"].as_str(), view["due"].as_array().map(Vec::len)), (Some(""), Some(2)));
5794        // The same answers in the records only, their marks not here yet: the 08:00 dose once, to choose.
5795        let view = serde_json::to_value(porch_doses(&health, &HealthState::default(), &records, &DoseReminders::default(), "desk-id", &alone, &noon, false, true, &english)).unwrap();
5796        let keys: Vec<&str> = view["due"].as_array().unwrap().iter().map(|row| row["key"].as_str().unwrap_or("")).collect();
5797        assert_eq!(keys.iter().filter(|key| **key == eight).count(), 1, "{view}");
5798        assert_eq!(view["due"][0]["choose"].as_bool(), Some(true), "{view}");
5799        let _ = std::fs::remove_dir_all(&dir);
5800    }
5801
5802    /// Settings ▸ Your folder and sharing lists your other devices: how each
5803    /// is, in words, with what can be done; nothing red, no counts.
5804    #[test]
5805    fn your_devices_listed_in_words() {
5806        use sioul_core::health::Said;
5807        use sioul_sync::devices::Entry;
5808        let english = Translator::new("en");
5809        let now = 1_800_000_000;
5810        let entry = |id: &str, kind: &str, name: &str, working: bool, exported: i64, doses: bool| Entry { id: id.into(), name: name.into(), kind: kind.into(), started: now - 7_200, closed: if working { 0 } else { exported + 5 }, working, exported, doses, ..Entry::default() };
5811        let mut kept = Peers::default();
5812        let mut add = |entry: Entry, seen: i64, ahead: i64| {
5813            let peer = Peer { id: entry.id.clone(), name: entry.name.clone(), said: Some(entry.said()), complete: true, seen, heard: entry.exported, ahead, ..Peer::default() };
5814            kept.peers.insert(entry.id.clone(), PeerSeen { peer, entry: Some(entry), ..PeerSeen::default() });
5815        };
5816        add(entry("a-phone", "phone", "FP3", false, now - 600, true), now - 590, 240);
5817        add(entry("b-desk", "computer", "desk", true, now - 30, true), now - 25, 0);
5818        add(entry("c-laptop", "computer", "laptop", true, now - 4 * 3_600, false), now - 4 * 3_600, 0);
5819        add(entry("d-old", "computer", "old", true, now - 9 * 86_400, true), now - 9 * 86_400, 0);
5820        kept.peers.insert("e-older".into(), PeerSeen { peer: Peer { id: "e-older".into(), name: "tower".into(), heard: now - 120, ..Peer::default() }, renewed: now - 120, ..PeerSeen::default() });
5821        kept.peers.get_mut("b-desk").unwrap().peer.off = true;
5822        let peers: Vec<Peer> = kept.peers.values().map(|s| s.peer.clone()).collect();
5823        let rows = device_rows_of(&peers, &kept, now, &english);
5824        let row = |id: &str| rows.iter().find(|r| r.id == id).unwrap();
5825        assert_eq!(row("a-phone").name, "The phone (FP3)");
5826        assert!(row("a-phone").state.starts_with("Closed ") && row("a-phone").notes == ["Its clock is 4 minutes ahead of this one's, at least."], "{:?}", row("a-phone"));
5827        assert!(row("b-desk").off && row("b-desk").state == "Counted as off, as you said, until it shares again.");
5828        assert!(row("c-laptop").state.starts_with("In use when it last shared") && row("c-laptop").notes == ["It does not share its doses."], "{:?}", row("c-laptop"));
5829        assert!(row("d-old").forget && row("d-old").state.starts_with("Silent since "), "{:?}", row("d-old"));
5830        assert!(row("e-older").state.starts_with("An older Sioul: last heard "), "{:?}", row("e-older"));
5831        assert!(rows.iter().all(|r| !r.state.contains("not taken")));
5832        let _ = Said::default();
5833        // The devices holding the newer form of what travels back: each has its
5834        // line; one not listed is added, with Forget; one listed keeps its own.
5835        let mut rows = rows;
5836        holder_rows(&mut rows, &[("e-older".into(), now - 120, true), ("f-gone".into(), now - 90 * 86_400, true), ("g-unknown".into(), 0, true), ("h-after".into(), now - 3_600, false)], &english);
5837        assert_eq!(rows.iter().filter(|r| r.id == "e-older").count(), 1);
5838        let row = |id: &str| rows.iter().find(|r| r.id == id).unwrap();
5839        assert!(!row("e-older").forget, "counted for the doses: forgotten once silent a week only");
5840        assert!(row("f-gone").forget && row("f-gone").name == "Another device of yours" && row("f-gone").state.starts_with("Known here by what it shared, last "), "{:?}", row("f-gone"));
5841        assert!(row("h-after").forget && row("h-after").state.starts_with("An older Sioul: last heard "), "{:?}", row("h-after"));
5842        assert!(row("g-unknown").forget && row("g-unknown").state == "Known here by what it shared: until it is updated there, or forgotten here, your devices keep sharing in the form an older Sioul reads.", "{:?}", row("g-unknown"));
5843    }
5844}