Skip to main content

sioul_app/
homecard.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The cards on a phone's home screen (docs/android.md, "The card on the
5//! home screen"). The full card: today first, the date and the weather (now,
6//! the next four hours, the next morning, afternoon, evening and night, then
7//! the next seven days); then what the Porch has for you that is not mail
8//! (today's doses due, a code you asked for, reminders of dates, waits,
9//! payments and papers, do-not-disturb's line, the calls declined, two events
10//! at once); then Now: the next step when it is the time for one, and where
11//! you stopped. The mail card and the agenda card list the latest messages
12//! the Porch shows (sender, date, subject, a line of the text, the account's
13//! colour) and the coming appointments, day by day, each in its calendar's
14//! colour. Never a count. Android draws them (android/package/src/com/
15//! aurelienpierre/sioul/HomeCard.java, the lists' rows HomeCardRows.java),
16//! without Qt: Sioul writes what they say in a small file of its own
17//! (`home-card.json`, in its state folder) and tells Android to draw them again.
18//!
19//! Android freezes Sioul in the background, so the card is given ahead, in
20//! frames: one per change of time until the end of tomorrow (work, admin, a
21//! meal, winding down, sleep, waking, the Porch's hours, midnight), each with
22//! its own words, the Porch's mail as it lets mail through then, the
23//! reminders and calls it lets show then, and the next step as the plan
24//! stands then. The weather is given hour by hour, from the forecast kept
25//! (40 hours and more ahead, `weather::card`), each hour with its own "now",
26//! next hours and parts of the day. The messages and the events are written
27//! once, each word that changes at midnight with until when it holds ("14:05",
28//! then "yesterday"; "Tomorrow, Thu 8 Oct", then "Today, Thu 8 Oct"). Java
29//! shows the frame and the hour for now, leaves out the events over, and draws
30//! again at the next change. Rust writes the card when the Porch is computed
31//! (mail fetched), when the plan is made, when the agenda is read again (an
32//! event changed), when a forecast or the reminders change, at the window's
33//! minute when a frame ended (midnight among them) or five minutes went by,
34//! and when Sioul is put away; outside the window, where Rust runs already (a
35//! dose's alarm, its "Taken"), it writes the doses' lines alone. Nothing here
36//! fetches anything. Elsewhere than on Android nothing is written, unless
37//! SIOUL_HOME_CARD is set (to read the file on a computer).
38
39use crate::backend::{load_config, tr};
40use jiff::civil::Date;
41use jiff::{Span, Timestamp, Zoned};
42use serde::Serialize;
43use sioul_core::agenda::Occurrence;
44use sioul_core::areas::{Area, Time};
45use sioul_core::projects::ProjectStore;
46use sioul_core::codes::CodeKind;
47use sioul_core::config::Config;
48use sioul_core::i18n::{self, Translator};
49use sioul_core::links::Loaded;
50use sioul_core::plan::Plan;
51use sioul_core::porch::{self, Lane, Senders, Triaged};
52use sioul_core::quiet::{self, Blocks, Mode, Overrides, Reason};
53use sioul_core::reminders::Reminder;
54use sioul_core::taskview::{self, Filter};
55use sioul_core::today::{Today, Weather};
56use sioul_core::trust::Trust;
57use sioul_core::weather::{self as forecast, DaySlot, Forecast, Slot};
58use sioul_core::{pause, view, window};
59use std::collections::{BTreeMap, BTreeSet};
60use std::path::PathBuf;
61use std::sync::atomic::{AtomicBool, Ordering};
62use std::sync::{Arc, Mutex};
63
64/// The card's file, in Sioul's state folder; Java reads it there
65/// (`files/state/sioul/`, android/main.cpp's XDG folders).
66const FILE: &str = "home-card.json";
67/// The file's version: 3 from the full card made around today (October 2026).
68/// Its frames are named "times": an older Sioul's Java reads "frames", finds
69/// none, and says `beyond` until Sioul opens. This Java reads both.
70const VERSION: u32 = 3;
71/// The reminders the card holds, at most, soonest first (Java shows a few).
72const REMINDERS: usize = 12;
73/// A forecast older than this is not shown: Sioul was not opened for a day.
74const FORECAST_KEPT: i64 = 24 * 3600;
75/// A tap on the card, kept by Java (HomeCardOpener.java) for the window.
76const OPENED: &str = "home-card-opened";
77/// The flag, among the task pages' (`work::WorkState`), that turns the details off.
78pub(crate) const PLAIN: &str = "home-card-plain";
79/// How long the card says nothing of its age: past it, "at 14:05".
80const FRESH_MS: i64 = 15 * 60 * 1000;
81/// While the window runs, the card is written again at least this often, its age said afresh.
82const REWRITE_EVERY: i64 = 5 * 60;
83/// The Porch's messages the card lists, at most, newest first.
84const MAIL: usize = 20;
85/// The agenda's reach: today and the days to come.
86const HORIZON_DAYS: i64 = 30;
87/// The appointments the card lists, at most, nearest first.
88const EVENTS: usize = 60;
89/// A message's line of text, at most (Java shows one line of it).
90const TEXT_CHARS: usize = 160;
91/// Frames at most: each change of time until the end of tomorrow.
92const FRAMES: usize = 48;
93/// A tap older than this is let go: Sioul did not come up meanwhile.
94const TAP_KEPT_MS: i64 = 5 * 60 * 1000;
95
96/// What the card says, as Java reads it (HomeCard.java). Times are Unix milliseconds.
97#[derive(Debug, Clone, PartialEq, Serialize)]
98pub(crate) struct Snapshot {
99    v: u32,
100    /// When Sioul made it.
101    made: i64,
102    /// "Details on the home screen" (Settings): off, no message, code, dose or step's title.
103    details: bool,
104    /// From then on, the card says how old it is (`stale`).
105    stale_after: i64,
106    /// "at 14:05" until midnight, "yesterday at 14:05" the next day, then a short date (`until` 0).
107    stale: Vec<Label>,
108    /// Said once the last frame is over: Sioul was not opened for that long.
109    beyond: String,
110    /// The lists' headings: "Mail", "Agenda".
111    words: Words,
112    /// Today, first on the full card: the date, the weather.
113    today: TodayLines,
114    /// The frames, from now to the end of tomorrow (`VERSION`: "times").
115    #[serde(rename = "times")]
116    frames: Vec<Frame>,
117    /// Every message a frame lists (`PorchLines::mail` points here), newest first.
118    mail: Vec<Mail>,
119    /// The coming appointments; none without a calendar.
120    agenda: Option<AgendaLines>,
121    /// The codes and links you asked sites for, each until it expires; newest first.
122    codes: Vec<Code>,
123    /// Today's doses due and not marked, from their time on.
124    doses: Vec<DoseLine>,
125    /// The reminders of dates, waits, payments, papers, contracts and the
126    /// money watch, each from its time while it makes sense, soonest first;
127    /// each frame says which it shows (`Frame::reminders`).
128    reminders: Vec<ReminderLine>,
129    /// The calls declined, in the Porch's words; each frame says which it lists.
130    calls: Vec<String>,
131    /// Two events at once, today's and tomorrow's, each said on its own day
132    /// until both are over.
133    overlaps: Vec<Timed>,
134    /// Where you stopped, the line you left, until you say it is done: "Where
135    /// you stopped: …"; details off, that it waits; "" none.
136    stopped: String,
137}
138
139/// Today, as the full card begins.
140#[derive(Debug, Clone, PartialEq, Serialize)]
141struct TodayLines {
142    /// "Thursday 8 October" until midnight, then "Friday 9 October".
143    date: Vec<Label>,
144    /// Said instead of the weather: no place chosen yet (where to choose
145    /// one), or no forecast yet; "" with the weather.
146    line: String,
147    weather: Option<WeatherLines>,
148}
149
150/// The weather at the place chosen, from the forecast kept.
151#[derive(Debug, Clone, PartialEq, Serialize)]
152struct WeatherLines {
153    /// Hour by hour, from the hour under way to the end of the forecast or of tomorrow.
154    hours: Vec<WeatherHour>,
155    /// The forecast's days, each from its midnight to the next: Java shows
156    /// the seven after today.
157    days: Vec<WeatherDay>,
158    /// "Weather: Open-Meteo.com", small, as its licence asks (CC BY 4.0).
159    credit: String,
160}
161
162/// One hour of the card's weather: what it says while the hour lasts.
163#[derive(Debug, Clone, PartialEq, Serialize)]
164struct WeatherHour {
165    from: i64,
166    until: i64,
167    /// "Now": its icon, "14°", "70 %".
168    now: Slot,
169    /// The next four hours, on the same row as now.
170    next: Vec<Slot>,
171    /// The next morning, afternoon, evening and night, in order.
172    parts: Vec<Slot>,
173}
174
175/// One day of the forecast, from its midnight to the next (Unix ms).
176#[derive(Debug, Clone, PartialEq, Serialize)]
177struct WeatherDay {
178    from: i64,
179    until: i64,
180    #[serde(flatten)]
181    day: DaySlot,
182}
183
184/// A reminder, as the card says it, from its time while it makes sense.
185#[derive(Debug, Clone, PartialEq, Serialize)]
186struct ReminderLine {
187    /// "Rent · €650 · Planned for Friday 10 October; the account will hold
188    /// it."; details off, "A reminder waits in Sioul."
189    line: String,
190    from: i64,
191    until: i64,
192    /// What a tap opens: its task, budget, paper or contract ("sioul:task/…").
193    open: String,
194}
195
196/// A line said from a time until another (Unix ms).
197#[derive(Debug, Clone, PartialEq, Serialize)]
198struct Timed {
199    line: String,
200    from: i64,
201    until: i64,
202}
203
204/// The list's headings, in the person's language.
205#[derive(Debug, Clone, PartialEq, Serialize)]
206struct Words {
207    mail: String,
208    agenda: String,
209}
210
211/// One stretch of time that says the same.
212#[derive(Debug, Clone, PartialEq, Serialize)]
213struct Frame {
214    from: i64,
215    until: i64,
216    /// "work", "admin", "work+admin", "any", "leisure", "meals", "sleep", "paused", "free-time".
217    kind: String,
218    /// The status line's sentence: "Work until 17:00.", "Sleep: nothing disturbs until 07:00."
219    status: String,
220    /// Under it, the do-not-disturb on every device, switched on or for a focus
221    /// session: "Do not disturb, on every device, until 15:00."; "" otherwise.
222    dnd: String,
223    /// What the Porch shows then; none asleep, in a pause or in free time.
224    porch: Option<PorchLines>,
225    /// The next step, in work or admin time.
226    step: Option<Step>,
227    /// Codes may show: as their row of the matrix says (as usual at once,
228    /// asleep too; "On the Porch only" a choice for sleep), never on the
229    /// pause's card, which shows the pause alone.
230    codes: bool,
231    /// Doses may show: not in a pause; asleep only when doses remind during sleep.
232    doses: bool,
233    /// The agenda shows: not in a pause, whose card shows the pause alone.
234    events: bool,
235    /// The reminders shown then, their places in `Snapshot::reminders`: as
236    /// the row of dates of the matrix of what reaches you lets them come
237    /// (work's in work's times only); none asleep, in a pause or in free time
238    /// as usual. Java shows each from its time.
239    reminders: Vec<usize>,
240    /// The calls declined listed then, their places in `Snapshot::calls`: at
241    /// the times their callers may reach you, as the Porch lists them.
242    calls: Vec<usize>,
243    /// Two events at once may show: not asleep, in a pause or in free time.
244    overlaps: bool,
245    /// Where you stopped shows: with the next step, in work or admin time.
246    stopped: bool,
247    /// Under the status line: "Sioul is not set up yet."; "" otherwise.
248    note: String,
249}
250
251/// The Porch's part of a frame, as written.
252#[derive(Debug, Clone, PartialEq, Serialize)]
253struct PorchLines {
254    /// Said instead of messages, or above none: "The Porch opens at 14:00.",
255    /// "Nothing waits on the Porch.", "Sioul is not set up yet.", details off
256    /// "Mail waits on the Porch."; "" when the messages say it.
257    line: String,
258    /// The messages listed then, newest first: their places in `Snapshot::mail`.
259    mail: Vec<usize>,
260}
261
262/// What the Porch shows at a time, before its messages are given their places.
263#[derive(Debug, Clone, PartialEq)]
264struct PorchMade {
265    line: String,
266    mail: Vec<Mail>,
267}
268
269impl PorchMade {
270    fn said(line: String) -> PorchMade {
271        PorchMade { line, mail: Vec::new() }
272    }
273}
274
275/// A message, as a mail card lists it.
276#[derive(Debug, Clone, PartialEq, Serialize)]
277struct Mail {
278    /// Its file, to open it (HomeCardOpener, then the Porch's Reader).
279    key: String,
280    /// The sender's name, else the address.
281    sender: String,
282    subject: String,
283    /// The start of its text, on one line.
284    text: String,
285    /// Not read yet: its sender and subject in bold.
286    unread: bool,
287    /// The account it came to, as its place among your accounts (Java's
288    /// colours, `sioul_home_card_account_N`, light and dark); -1 none.
289    account: i32,
290    /// When it came: "14:05" today, "Yesterday" the next day, "7 Oct" after
291    /// (the year too when not this year's), each until its time; 0 for good.
292    when: Vec<Label>,
293}
294
295/// The coming appointments, from today to a month on.
296#[derive(Debug, Clone, PartialEq, Serialize)]
297struct AgendaLines {
298    /// Said when none comes: "Nothing in your calendars for the coming month."
299    empty: String,
300    /// Every day of the reach, in order: Java lists each event under the day
301    /// it starts, or today while it runs.
302    days: Vec<Day>,
303    /// The events, nearest first; Java leaves out those over.
304    events: Vec<Event>,
305}
306
307/// One day of the agenda, and its name as it changes at each midnight.
308#[derive(Debug, Clone, PartialEq, Serialize)]
309struct Day {
310    /// Its midnight, and the next one (Unix ms).
311    from: i64,
312    until: i64,
313    /// "Mon 12 Oct", then "Tomorrow, Mon 12 Oct", then "Today, Mon 12 Oct", each until its time.
314    label: Vec<Label>,
315}
316
317/// One appointment, as an agenda card shows it.
318#[derive(Debug, Clone, PartialEq, Serialize)]
319struct Event {
320    /// Its file, to open it (HomeCardOpener, then the Agenda).
321    key: String,
322    /// Its title; details off, "An event".
323    title: String,
324    /// "11:00 – 12:00"; a whole day, ""; several, "Until Fri 9 Oct".
325    time: String,
326    all_day: bool,
327    /// Unix ms.
328    start: i64,
329    end: i64,
330    /// Its calendar's colour, "#4c6b5c"; "" for Sioul's own.
331    color: String,
332}
333
334#[derive(Debug, Clone, PartialEq, Serialize)]
335struct Step {
336    title: String,
337    /// Why it comes now; details off, its date or its length.
338    why: String,
339    uid: String,
340}
341
342#[derive(Debug, Clone, PartialEq, Serialize)]
343struct Code {
344    /// "Code from La Banque (verified): 482913"; details off, "A code waits on the Porch."
345    line: String,
346    /// Its sender is not verified: use it only if you just asked for it.
347    warning: String,
348    until: i64,
349}
350
351#[derive(Debug, Clone, PartialEq, Serialize)]
352struct DoseLine {
353    /// "Magnesium · 300 mg, 12:30", while it is known not marked elsewhere.
354    line: String,
355    /// "Magnesium · 300 mg, 12:30: check before taking it.", once that is not known.
356    check: String,
357    from: i64,
358    until: i64,
359    /// `line` until then, `check` after; 0: `check` from the start.
360    known_until: i64,
361}
362
363#[derive(Debug, Clone, PartialEq, Serialize)]
364struct Label {
365    /// Said until then; 0, for good.
366    until: i64,
367    text: String,
368}
369
370/// A dose for the card, as the Health page knows it (`health::card_doses`).
371#[derive(Debug, Clone, PartialEq)]
372pub(crate) struct Dose {
373    /// "Magnesium · 300 mg".
374    pub name: String,
375    /// "12:30".
376    pub time: String,
377    /// When it is due, and until when it stays on the card (Unix seconds).
378    pub at: i64,
379    pub until: i64,
380    /// Until when it is known not marked on another device; 0: not known from its time on.
381    pub known_until: i64,
382}
383
384/// The Porch's messages, as the window last gathered them, before the hours sort them.
385pub(crate) struct PorchInput {
386    items: Vec<Triaged>,
387    senders: Senders,
388    store: Option<ProjectStore>,
389}
390
391/// The plan, as the window last made it.
392pub(crate) struct PlanInput {
393    loaded: Arc<Loaded>,
394    plan: Plan,
395    today: Today,
396    spent: BTreeMap<String, u32>,
397    stopped: BTreeMap<String, String>,
398}
399
400/// The calendars' events from today's midnight to the reach's end, as last
401/// read (`agenda_input`), and whether there is a calendar at all.
402pub(crate) struct AgendaInput {
403    events: Vec<Occurrence>,
404    calendars: bool,
405    /// The day they were read for: read again the next day.
406    read_on: Date,
407    /// Two events at once set aside for good (`overlaps::SetAside`): not said.
408    aside: BTreeSet<String>,
409}
410
411/// The weather the card is made with: whether a place is chosen, and the
412/// forecast kept (`backend::update_weather`).
413#[derive(Default)]
414pub(crate) struct WeatherInput {
415    place: bool,
416    forecast: Option<Forecast>,
417}
418
419/// The calls declined as the Porch lists them at a time (`calls::for_card`):
420/// their lines, seen from the time given, as the moment given lets their callers through.
421type CallsAt<'a> = &'a dyn Fn(&sioul_core::attention::Now, &Zoned) -> Vec<String>;
422
423/// What the full card is made from besides the Porch, the plan, the agenda and the doses.
424#[derive(Default)]
425struct More<'a> {
426    weather: WeatherInput,
427    /// Every reminder, told or not (`reminders::all`); the events' left out here.
428    reminders: &'a [Reminder],
429    calls: Option<CallsAt<'a>>,
430    /// Where you stopped: the line left.
431    stopped: Option<String>,
432}
433
434/// What a card is made from, besides the Porch and the plan.
435struct Moment<'a> {
436    now: &'a Zoned,
437    config: &'a Config,
438    overrides: &'a Overrides,
439    blocks: &'a Blocks,
440    tr: &'a Translator,
441    details: bool,
442    /// The do-not-disturb on every device, when the card says it (`Dnd::of`).
443    dnd: Option<&'a Dnd>,
444}
445
446/// The do-not-disturb on every device, as the card says it: switched on by
447/// hand or for a focus session. Sleep, the pause and free time say their own
448/// words in the status line, so they add none here.
449#[derive(Debug, Clone, PartialEq)]
450struct Dnd {
451    /// "Do not disturb, on every device, until 15:00.", in the person's language.
452    line: String,
453    /// When it ends (Unix seconds); 0 when it lasts until switched off; none
454    /// when not said: then said in the frame for now only.
455    until: Option<i64>,
456}
457
458impl Dnd {
459    /// From the shared do-not-disturb's moment (`everywhere::moment`): {on,
460    /// why, line, until_at}; none when it is off, or for a reason the status
461    /// line says already.
462    fn of(moment: &serde_json::Value) -> Option<Dnd> {
463        let why = moment["why"].as_str().unwrap_or_default();
464        let line = moment["line"].as_str().unwrap_or_default().trim();
465        if moment["on"].as_bool() != Some(true) || !matches!(why, "manual" | "focus") || line.is_empty() {
466            return None;
467        }
468        // A focus session's end is no promise (it is stopped, or runs over):
469        // said in the frame for now only, unless an end is given.
470        let until = match (why, moment["until_at"].as_i64()) {
471            ("focus", Some(0)) => None,
472            (_, until) => until,
473        };
474        Some(Dnd { line: line.to_string(), until })
475    }
476
477    /// Said in a frame starting at `from` (Unix ms): before it ends; never
478    /// asleep (the night's line says nothing disturbs) nor in a pause (its
479    /// line alone); its end not said, only in the frame for now.
480    fn line_in(&self, kind: &str, from: i64, first: bool) -> String {
481        let before_end = match self.until {
482            Some(0) => true,
483            Some(until) => from < until * 1000,
484            None => first,
485        };
486        if before_end && kind != "sleep" && kind != "paused" { self.line.clone() } else { String::new() }
487    }
488}
489
490fn ms(at: &Zoned) -> i64 {
491    at.timestamp().as_millisecond()
492}
493
494/// A message with its words given, in `tr`'s language.
495fn said(tr: &Translator, id: &str, pairs: &[(&str, String)]) -> String {
496    let mut args = i18n::args();
497    for (key, value) in pairs {
498        args.set(key.to_string(), value.clone());
499    }
500    tr.text(id, Some(&args))
501}
502
503/// The midnight after `at`, in its zone.
504fn midnight_after(at: &Zoned) -> Option<Zoned> {
505    at.date().tomorrow().ok().and_then(|d| d.to_zoned(at.time_zone().clone()).ok())
506}
507
508/// "at 14:00", "tomorrow at 9:00", "on Monday 12 October at 09:00", as seen from `from`.
509fn when(tr: &Translator, at: &Zoned, from: &Zoned) -> String {
510    let time = at.strftime("%H:%M").to_string();
511    if at.date() == from.date() {
512        said(tr, "home-card-when-at", &[("time", time)])
513    } else if from.date().tomorrow().is_ok_and(|d| d == at.date()) {
514        said(tr, "until-tomorrow", &[("time", time)])
515    } else {
516        tr.when(at)
517    }
518}
519
520/// "Jeudi 8 octobre": a heading's first letter a capital, whatever the language writes.
521fn capital(text: &str) -> String {
522    let mut chars = text.chars();
523    chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
524}
525
526/// Today's date, as the full card begins: "Thursday 8 October" until
527/// midnight, then "Friday 9 October" until the next (the frames end then).
528fn date_labels(tr: &Translator, now: &Zoned) -> Vec<Label> {
529    let mut labels = Vec::new();
530    let mut day = now.date();
531    for _ in 0..2 {
532        let Some(midnight) = day.tomorrow().ok().and_then(|d| d.to_zoned(now.time_zone().clone()).ok()) else { break };
533        labels.push(Label { until: ms(&midnight), text: capital(&tr.day(day)) });
534        day = midnight.date();
535    }
536    labels
537}
538
539/// Today, as the full card begins: the date, then the weather at the place
540/// chosen, hour by hour from the hour under way until `end` (the last frame's)
541/// or the forecast's end, and its days. No place: where to choose one. No
542/// forecast, or one older than a day: that none is there yet. The weather is
543/// no detail: shown with the details off too.
544fn today_lines(m: &Moment, weather: &WeatherInput, end: i64) -> TodayLines {
545    let date = date_labels(m.tr, m.now);
546    if !weather.place {
547        return TodayLines { date, line: m.tr.text("home-card-weather-choose", None), weather: None };
548    }
549    let now = m.now.timestamp().as_second();
550    let Some(kept) = weather.forecast.as_ref().filter(|f| now - f.fetched < FORECAST_KEPT && f.hours.iter().any(|h| h.at + 3600 > now)) else {
551        return TodayLines { date, line: m.tr.text("weather-none", None), weather: None };
552    };
553    let zone = m.now.time_zone().clone();
554    let hours = kept
555        .hours
556        .iter()
557        .filter(|h| h.at + 3600 > now && h.at * 1000 < end)
558        .filter_map(|h| {
559            let at = Timestamp::from_second(h.at).ok()?.to_zoned(zone.clone());
560            let view = forecast::card(kept, &at, m.tr)?;
561            Some(WeatherHour { from: h.at * 1000, until: (h.at + 3600) * 1000, now: view.now?, next: view.hours, parts: view.parts })
562        })
563        .collect();
564    let days = forecast::days(kept, m.tr)
565        .into_iter()
566        .filter_map(|day| {
567            let from = day.date.to_zoned(zone.clone()).ok()?;
568            let until = day.date.tomorrow().ok()?.to_zoned(zone.clone()).ok()?;
569            Some(WeatherDay { from: ms(&from), until: ms(&until), day })
570        })
571        .collect();
572    TodayLines { date, line: String::new(), weather: Some(WeatherLines { hours, days, credit: m.tr.text("home-card-weather-credit", None) }) }
573}
574
575/// The reminders the card says: those of dates, waits, payments, papers,
576/// contracts and the money watch, from their time while they make sense
577/// (the events' come as Android's notifications, and on the agenda card),
578/// soonest first, in one line each: what, then when or why. Details off,
579/// none named. Each with whether it is work's (it waits while work rests).
580fn reminder_lines(tr: &Translator, all: &[Reminder], details: bool, now: i64) -> Vec<(ReminderLine, bool)> {
581    use sioul_core::reminders::Kind;
582    let mut kept: Vec<&Reminder> = all.iter().filter(|r| !matches!(r.kind, Kind::Event | Kind::Before | Kind::Alarm) && r.until > now).collect();
583    kept.sort_by(|a, b| (a.at, &a.key).cmp(&(b.at, &b.key)));
584    kept.into_iter()
585        .take(REMINDERS)
586        .map(|r| {
587            let parts: Vec<&str> = match r.kind {
588                // "Call the CAF · Asked for Friday 30 October".
589                Kind::Asked => vec![&r.body, &r.title],
590                // A paper's and the money watch's titles say it; their bodies are advice, on the Porch.
591                Kind::Paper | Kind::Money => vec![&r.title],
592                _ => vec![&r.title, &r.body],
593            };
594            let line = if details { parts.into_iter().map(str::trim).filter(|p| !p.is_empty()).collect::<Vec<_>>().join(" · ") } else { tr.text("home-card-reminder-plain", None) };
595            (ReminderLine { line, from: r.at * 1000, until: r.until * 1000, open: r.target.clone() }, r.work)
596        })
597        .collect()
598}
599
600/// Two events at once today and tomorrow, each said on its own day until
601/// both are over, as the Porch says today's (`overlaps`): the time to get
602/// there and back counted; those set aside for good left out.
603fn overlap_lines(m: &Moment, input: &AgendaInput) -> Vec<Timed> {
604    let zone = m.now.time_zone().clone();
605    let now = m.now.timestamp().as_second();
606    let day_of = |at: i64| Timestamp::from_second(at).ok().map(|t| t.to_zoned(zone.clone()).date());
607    let tomorrow = m.now.date().tomorrow().ok();
608    sioul_core::overlaps::overlaps(&input.events)
609        .into_iter()
610        .filter(|o| !input.aside.contains(&o.key) && o.first.end.max(o.second.end) > now)
611        .filter_map(|o| {
612            let day = day_of(o.second.start)?;
613            if day != m.now.date() && Some(day) != tomorrow {
614                return None;
615            }
616            let from = day.to_zoned(zone.clone()).ok()?;
617            let title = |e: &Occurrence| if e.summary.trim().is_empty() { m.tr.text("agenda-untitled", None) } else { e.summary.trim().to_string() };
618            let line = if m.details { said(m.tr, "home-card-overlap", &[("first", title(&o.first)), ("second", title(&o.second))]) } else { m.tr.text("home-card-overlap-plain", None) };
619            Some(Timed { line, from: ms(&from), until: o.first.end.max(o.second.end) * 1000 })
620        })
621        .collect()
622}
623
624/// The changes of time from now to the end of tomorrow (Health's meals and
625/// nights are known that far: `Needs::kept_around`), each with what it is
626/// for: a frame ends when the time changes (`quiet::mode`'s `until`), when a
627/// meal, a nap or the night begins, starts proper (winding down becomes
628/// sleep at bedtime) or ends, when the Porch opens or closes, when it stops
629/// resting after a pause, and at midnight, so that "tomorrow" in a frame's
630/// words stays true.
631fn timeline(m: &Moment) -> Vec<(Zoned, Zoned, Mode)> {
632    let Some(horizon) = midnight_after(m.now).and_then(|z| midnight_after(&z)) else { return Vec::new() };
633    let hours = m.config.week_hours();
634    let zone = m.now.time_zone().clone();
635    let instant = |seconds: i64| Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()));
636    let rests = m.overrides.porch_rests_until.and_then(instant);
637    // The do-not-disturb's end too: its line goes then.
638    let quiet_end = m.dnd.and_then(|d| d.until).filter(|until| *until > 0).and_then(instant);
639    let kept: Vec<Zoned> = m.blocks.kept.iter().flat_map(|k| [k.start, k.at, k.end]).filter_map(instant).chain(quiet_end).collect();
640    let mut frames = Vec::new();
641    let mut at = m.now.clone();
642    while at.timestamp() < horizon.timestamp() && frames.len() < FRAMES {
643        let mode = quiet::mode(&hours, &m.config.time_off, m.overrides, m.blocks, &at);
644        let mut ends = vec![horizon.clone()];
645        ends.extend(midnight_after(&at));
646        ends.extend(mode.until.clone());
647        ends.extend(kept.iter().cloned());
648        ends.extend(window::current(&m.config.windows, &at).map(|(_, closing)| closing));
649        ends.extend(window::next_opening(&m.config.windows, &at));
650        ends.extend(rests.clone());
651        let end = ends
652            .into_iter()
653            .filter(|e| e.timestamp() > at.timestamp())
654            .min_by_key(Zoned::timestamp)
655            .unwrap_or_else(|| at.checked_add(Span::new().minutes(1)).unwrap_or_else(|_| horizon.clone()));
656        frames.push((at.clone(), end.clone(), mode));
657        at = end;
658    }
659    frames
660}
661
662/// What a frame is, for Java's colours and tests: the time's word, or the pause's.
663fn kind(mode: &Mode) -> &'static str {
664    match mode.reason {
665        Reason::Paused => "paused",
666        Reason::FreeTime => "free-time",
667        _ => mode.time.id(),
668    }
669}
670
671/// The status line's sentence for `mode`, as seen from `at` (`backend::mode_json`'s,
672/// and "Work until 17:00." in working hours, where the window's line says nothing).
673fn status(m: &Moment, mode: &Mode, at: &Zoned) -> String {
674    let until = mode.until.as_ref().map(|z| quiet::until_text(m.tr, z, at)).unwrap_or_default();
675    let id = match mode.reason {
676        Reason::FreeTime => return m.tr.text(if pause::nothing_now(m.overrides, &m.config.free_time) { "mode-free-time-nothing" } else { "mode-free-time" }, None),
677        Reason::Paused => return m.tr.text("mode-paused", None),
678        // Every other sentence says until when: none known, none said.
679        _ if until.is_empty() => return String::new(),
680        Reason::NoHours => return String::new(),
681        Reason::Working | Reason::Extended => "home-card-work",
682        Reason::TimeOff => "mode-time-off",
683        Reason::WorkingLate => "mode-working-late",
684        Reason::WorkNow => "mode-work-now-line",
685        Reason::AdminTime => "mode-admin",
686        Reason::DoneForTheDay => "mode-quiet",
687        Reason::Evening | Reason::DayOff => "mode-leisure",
688        Reason::Meal => "mode-meal",
689        Reason::WindingDown => "mode-wind-down",
690        Reason::Nap => "mode-nap",
691        Reason::Sleep => "mode-sleep",
692    };
693    let mut args = i18n::args();
694    args.set("until", until);
695    args.set("label", if mode.label.trim().is_empty() { "none".to_string() } else { mode.label.clone() });
696    m.tr.text(id, Some(&args))
697}
698
699/// "Thu 8 Oct", "jeu. 8 oct.": a day as an agenda card names it.
700fn day_short(tr: &Translator, day: Date) -> String {
701    let mut args = i18n::args();
702    args.set("weekday", tr.weekday_short(day));
703    args.set("day", day.day());
704    args.set("month", tr.month_short(day));
705    tr.text("date-day", Some(&args))
706}
707
708/// "7 Oct", "7 oct.", "1er oct.": a message's day, as a mail card dates it;
709/// with its year when it is not `today`'s.
710fn day_month_short(tr: &Translator, day: Date, today: Date) -> String {
711    let mut args = i18n::args();
712    args.set("day", day.day());
713    args.set("month", tr.month_short(day));
714    let said = tr.text("date-day-month", Some(&args));
715    if day.year() == today.year() { said } else { format!("{said} {}", day.year()) }
716}
717
718/// When a message came, as the card dates it, each wording until it holds no
719/// longer (Java picks the first whose `until` is ahead, 0 for good): its time
720/// today; "Yesterday" from midnight; its day after. Seen from `now`.
721fn mail_when(tr: &Translator, sent: Option<i64>, now: &Zoned) -> Vec<Label> {
722    let Some(at) = sent.and_then(|s| Timestamp::from_second(s).ok()).map(|t| t.to_zoned(now.time_zone().clone())) else { return Vec::new() };
723    let (day, today) = (at.date(), now.date());
724    let first = midnight_after(now);
725    let second = first.as_ref().and_then(midnight_after);
726    let yesterday = tr.text("home-card-yesterday", None);
727    let mut labels = Vec::new();
728    match (first, second) {
729        (Some(first), Some(second)) if day == today => {
730            labels.push(Label { until: ms(&first), text: at.strftime("%H:%M").to_string() });
731            labels.push(Label { until: ms(&second), text: yesterday });
732        }
733        (Some(first), _) if today.yesterday().is_ok_and(|y| y == day) => labels.push(Label { until: ms(&first), text: yesterday }),
734        _ => {}
735    }
736    labels.push(Label { until: 0, text: day_month_short(tr, day, today) });
737    labels
738}
739
740/// A message the Porch shows, as the card lists it: from the Porch's line
741/// (`item`, which keeps hostile and rude words out) and its message (`triaged`:
742/// when it came, whether it was read, the account it came to). With when it
743/// came, for the order.
744fn mail_line(m: &Moment, item: &view::ItemView, triaged: Option<&Triaged>) -> (i64, Mail) {
745    let sent = triaged.and_then(|t| t.card.date);
746    let account = item.account.as_deref().and_then(|id| m.config.every_account().position(|a| a.id == id)).and_then(|at| i32::try_from(at).ok()).unwrap_or(-1);
747    // Read, by its file's flags (`S`), as the Mail page says it; a file not named so (new/): not read.
748    let unread = !sioul_core::maildir::flags_of(std::path::Path::new(&item.key)).contains('S');
749    let text: String = item.preview.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(TEXT_CHARS).collect();
750    let subject = if item.subject.trim().is_empty() { m.tr.text("mail-no-subject", None) } else { item.subject.clone() };
751    (sent.unwrap_or(0), Mail { key: item.key.clone(), sender: item.sender.clone(), subject, text, unread, account, when: mail_when(m.tr, sent, m.now) })
752}
753
754/// What the Porch shows at `at`, as `backend::compute` makes it: the mail
755/// the matrix of what reaches you shows at that time (`Attention::mail`,
756/// Free time's "Nothing at all" too), the Porch open in its hours and in quiet time, closed
757/// otherwise with when it opens, resting after a pause until its next hours
758/// (`pauses::porch_rests`). Never "Open it anyway": a moment's choice in the
759/// window, not the card's. Its messages newest first, as a mail card lists
760/// them; never a count.
761fn porch_at(m: &Moment, input: &PorchInput, mode: &Mode, at: &Zoned) -> PorchMade {
762    if let Some(until) = m.overrides.porch_rests_until.filter(|_| m.overrides.porch_rests(at.timestamp().as_second())).and_then(|u| Timestamp::from_second(u).ok()) {
763        let until = until.to_zoned(at.time_zone().clone());
764        return PorchMade::said(said(m.tr, "home-card-porch-rests", &[("when", when(m.tr, &until, at))]));
765    }
766    let attention = sioul_core::attention::Attention::of(m.config);
767    let mut moment = sioul_core::attention::Now::of(mode);
768    moment.nothing = mode.free() && pause::nothing_now(m.overrides, &m.config.free_time);
769    let always = sioul_core::everywhere::People::load(&sioul_core::everywhere::People::default_path());
770    let area_of = |t: &Triaged| t.card.account.as_deref().and_then(|id| m.config.account(id)).and_then(|a| a.area.as_deref()).and_then(Area::parse).unwrap_or(Area::WORK);
771    let mail: Vec<Triaged> = input.items.iter().filter(|t| attention.mail(t, &input.senders, &always, area_of(t), &moment).shown).cloned().collect();
772    let shown = view::porch(&mail, m.config, input.store.as_ref(), m.tr, at, mode.quiet);
773    if !shown.open {
774        return PorchMade::said(match window::next_opening(&m.config.windows, at) {
775            Some(next) => said(m.tr, "home-card-porch-opens", &[("when", when(m.tr, &next, at))]),
776            None => m.tr.text("home-card-porch-closed", None),
777        });
778    }
779    // The lanes the Porch shows unfolded; never what it folds (filed, less
780    // important), whose words stay behind a click, and never what is set
781    // aside, waits for your word on spam, or is hostile, even unfolded.
782    let triaged: BTreeMap<String, &Triaged> = mail.iter().filter_map(|t| t.card.path.as_ref().map(|p| (p.display().to_string(), t))).collect();
783    let mut listed: Vec<(i64, Mail)> = shown
784        .lanes
785        .iter()
786        .filter(|l| !l.folded && !matches!(l.key.as_str(), "set-aside" | "review" | "hostile"))
787        .flat_map(|l| l.items.iter())
788        .filter(|i| !i.hidden && !i.spam)
789        .map(|i| mail_line(m, i, triaged.get(&i.key).copied()))
790        .collect();
791    if listed.is_empty() {
792        return PorchMade::said(m.tr.text("home-card-porch-empty", None));
793    }
794    // Details off: that mail waits, no one named, no count.
795    if !m.details {
796        return PorchMade::said(m.tr.text("home-card-mail-waits", None));
797    }
798    // The newest first, as a mail card lists them.
799    listed.sort_by_key(|(sent, _)| std::cmp::Reverse(*sent));
800    PorchMade { line: String::new(), mail: listed.into_iter().take(MAIL).map(|(_, mail)| mail).collect() }
801}
802
803/// A calendar's colour as "#rrggbb", whatever its tool wrote ("#4C6B5CFF");
804/// "" when it gave none, or one that does not read.
805fn colour(color: Option<&str>) -> String {
806    let Some(color) = color.map(str::trim).filter(|c| c.starts_with('#')) else { return String::new() };
807    let hex: String = color.chars().skip(1).take(6).collect();
808    if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) { format!("#{}", hex.to_ascii_lowercase()) } else { String::new() }
809}
810
811/// An event's time, as an agenda card says it: "11:00 – 12:00"; ending
812/// another day, "23:00 – Fri 9 Oct 01:00"; a whole day, nothing; several
813/// whole days, "Until Fri 9 Oct".
814fn event_time(tr: &Translator, event: &Occurrence, zone: &jiff::tz::TimeZone) -> String {
815    let at = |seconds: i64| Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()));
816    let (Some(start), Some(end)) = (at(event.start), at(event.end.max(event.start))) else { return String::new() };
817    // The last moment it covers: an end at midnight belongs to the day before.
818    let last = at(event.end.max(event.start + 1) - 1).map_or_else(|| start.date(), |z| z.date());
819    if event.all_day {
820        return if last > start.date() { said(tr, "home-card-until-day", &[("day", day_short(tr, last))]) } else { String::new() };
821    }
822    let from = start.strftime("%H:%M").to_string();
823    if event.end <= event.start {
824        return from;
825    }
826    let to = if last == start.date() { end.strftime("%H:%M").to_string() } else { tr.date(&end, true) };
827    said(tr, "home-card-time-range", &[("start", from), ("end", to)])
828}
829
830/// A day of the agenda and its names: "Mon 12 Oct" until the day before,
831/// "Tomorrow, Mon 12 Oct" that day, "Today, Mon 12 Oct" on the day; seen from `today`.
832fn agenda_day(tr: &Translator, day: Date, today: Date, zone: &jiff::tz::TimeZone) -> Option<Day> {
833    let midnight = |d: Date| d.to_zoned(zone.clone()).ok().map(|z| ms(&z));
834    let (from, until) = (midnight(day)?, midnight(day.tomorrow().ok()?)?);
835    let name = day_short(tr, day);
836    let mut label = Vec::new();
837    if day > today.tomorrow().ok()? {
838        label.push(Label { until: midnight(day.yesterday().ok()?)?, text: name.clone() });
839    }
840    if day > today {
841        label.push(Label { until: from, text: said(tr, "home-card-tomorrow", &[("day", name.clone())]) });
842    }
843    label.push(Label { until, text: said(tr, "home-card-today", &[("day", name)]) });
844    Some(Day { from, until, label })
845}
846
847/// The coming appointments, from today to a month on: those not over yet,
848/// cancelled ones left out, nearest first; each under its day (Java's), in
849/// its calendar's colour. Details off, none named: "An event", its time.
850/// None without a calendar.
851fn agenda_lines(m: &Moment, input: &AgendaInput) -> Option<AgendaLines> {
852    if !input.calendars {
853        return None;
854    }
855    let zone = m.now.time_zone().clone();
856    let today = m.now.date();
857    let now = m.now.timestamp().as_second();
858    let end = today.checked_add(Span::new().days(HORIZON_DAYS + 1)).ok().and_then(|d| d.to_zoned(zone.clone()).ok()).map_or(i64::MAX, |z| z.timestamp().as_second());
859    // An instant event still takes its minute.
860    let mut coming: Vec<&Occurrence> = input.events.iter().filter(|e| !e.cancelled && e.end.max(e.start + 60) > now && e.start < end).collect();
861    // Nearest first, a day's whole-day events before its times (Java's days only go forward).
862    coming.sort_by(|a, b| (a.start, !a.all_day, &a.summary).cmp(&(b.start, !b.all_day, &b.summary)));
863    let events = coming
864        .into_iter()
865        .take(EVENTS)
866        .map(|e| Event {
867            key: e.key.clone(),
868            title: match e.summary.trim() {
869                _ if !m.details => m.tr.text("home-card-event-plain", None),
870                "" => m.tr.text("agenda-untitled", None),
871                title => title.to_string(),
872            },
873            time: event_time(m.tr, e, &zone),
874            all_day: e.all_day,
875            start: e.start * 1000,
876            end: e.end.max(e.start + 60) * 1000,
877            color: colour(e.color.as_deref()),
878        })
879        .collect();
880    let days = (0..=HORIZON_DAYS).filter_map(|i| today.checked_add(Span::new().days(i)).ok()).filter_map(|day| agenda_day(m.tr, day, today, &zone)).collect();
881    Some(AgendaLines { empty: m.tr.text("home-card-agenda-empty", None), days, events })
882}
883
884/// Whether a step is for this time: work or admin time (or no hours set),
885/// never asleep, in a pause, in free time, at leisure or during a meal.
886fn time_for_a_step(mode: &Mode) -> bool {
887    matches!(mode.time, Time::Work | Time::Admin | Time::Several(_) | Time::Any) && !mode.sleeps() && !mode.paused() && !mode.free()
888}
889
890/// The next step at `at`, as Now would pick it then: only in work or admin
891/// time (or with no hours set), never asleep, in a pause, in free time, at
892/// leisure or during a meal; offices open or not then, "Not now" and the
893/// day's weather today, a new day starting clean. The page's own filters
894/// (one kind, one category) are the page's, not the card's.
895fn step_at(m: &Moment, input: &PlanInput, mode: &Mode, at: &Zoned) -> Option<Step> {
896    if !time_for_a_step(mode) {
897        return None;
898    }
899    let situation = quiet::Situation::now(m.config, m.overrides, m.blocks, at, m.tr, &input.loaded.projects);
900    let filter = Filter { quiet: situation.quiet_tasks(), ..Filter::default() };
901    let today = input.today.date == at.date().to_string();
902    let (weather, aside) = if today { (input.today.weather, input.today.aside.clone()) } else { (Weather::Clear, BTreeSet::new()) };
903    let cx = taskview::Context { filter: &filter, offices: situation.offices.clone(), tasks: &input.loaded.tasks, plan: &input.plan, today: at.date(), tr: m.tr, projects: &input.loaded.projects, spent: &input.spent, stopped: &input.stopped };
904    let now = taskview::now(&cx, weather, &aside);
905    let card = now.now?;
906    if m.details {
907        return Some(Step { title: card.title, why: now.why.first().cloned().unwrap_or_default(), uid: card.uid });
908    }
909    // Without its title, nor a reason that names another step: its date, else its length.
910    Some(Step { title: m.tr.text("home-card-step-plain", None), why: if card.due.is_empty() { card.estimate } else { card.due }, uid: card.uid })
911}
912
913/// A code's kind, as the card's words name it.
914fn kind_word(kind: CodeKind) -> &'static str {
915    match kind {
916        CodeKind::Code => "code",
917        CodeKind::Password => "password",
918        CodeKind::PasswordReset => "reset",
919        CodeKind::SignInLink => "link",
920        CodeKind::Confirmation => "confirm",
921    }
922}
923
924/// The codes and links you asked sites for, still valid, newest first: each
925/// as the Porch's "Right now" says it, its site and the code, with the
926/// warning when its sender is not verified, until it expires (`porch::sent`
927/// and its validity, as the Porch hides it). Details off, one line without
928/// site or code: "A code waits on the Porch."
929fn codes(m: &Moment, input: &PorchInput) -> Vec<Code> {
930    let now = m.now.timestamp().as_second();
931    let mut found: Vec<(i64, Code, CodeKind)> = input
932        .items
933        .iter()
934        .filter(|t| t.lane == Lane::RightNow)
935        .filter_map(|t| {
936            let code = t.code.as_ref()?;
937            let lasts = i64::from(code.lasts_minutes()) * 60;
938            let sent = porch::sent(&t.card).unwrap_or(now);
939            let until = sent + lasts;
940            let line = m.tr.right_now(code.kind, t.card.sender(), t.trust, code.code.as_deref(), None);
941            let warning = if t.trust == Trust::Verified { String::new() } else { m.tr.text("right-now-unverified", None) };
942            (until > now).then(|| (sent, Code { line, warning, until: until * 1000 }, code.kind))
943        })
944        .collect();
945    found.sort_by_key(|(sent, _, _)| std::cmp::Reverse(*sent));
946    if m.details {
947        return found.into_iter().map(|(_, code, _)| code).collect();
948    }
949    let last = found.iter().map(|(_, code, _)| code.until).max();
950    found.first().zip(last).map(|((_, _, kind), until)| Code { line: said(m.tr, "home-card-code-plain", &[("kind", kind_word(*kind).to_string())]), warning: String::new(), until }).into_iter().collect()
951}
952
953/// Today's doses in words: "Magnesium · 300 mg, 12:30", and once that it was
954/// not marked elsewhere is no longer known, "…: check before taking it."
955/// Never "missed", "late" or "not taken" (docs/health.md, "Knowing"). None
956/// with the details off: a medicine's name, even "a dose", says health to
957/// whoever shares the launcher.
958fn dose_lines(tr: &Translator, doses: &[Dose], details: bool) -> Vec<DoseLine> {
959    if !details {
960        return Vec::new();
961    }
962    doses
963        .iter()
964        .map(|d| {
965            let pairs = [("dose", d.name.clone()), ("time", d.time.clone())];
966            DoseLine { line: said(tr, "home-card-dose", &pairs), check: said(tr, "home-card-dose-check", &pairs), from: d.at * 1000, until: d.until * 1000, known_until: d.known_until * 1000 }
967        })
968        .collect()
969}
970
971/// How old the card is, said once it is: "at 14:05" until midnight,
972/// "yesterday at 14:05" the next day, a short date after.
973fn labels(tr: &Translator, made: &Zoned) -> Vec<Label> {
974    let time = made.strftime("%H:%M").to_string();
975    let first = midnight_after(made);
976    let second = first.as_ref().and_then(midnight_after);
977    vec![
978        Label { until: first.as_ref().map_or(0, ms), text: said(tr, "home-card-when-at", &[("time", time.clone())]) },
979        Label { until: second.as_ref().map_or(0, ms), text: said(tr, "home-card-when-yesterday", &[("time", time)]) },
980        Label { until: 0, text: tr.date(made, true) },
981    ]
982}
983
984/// What makes the Porch at `at` say what it says (`porch_at`): two frames
985/// alike are made once. Its mail by the time it is for; open or closed, and
986/// when it opens, from which day; resting after a pause.
987fn porch_key(m: &Moment, mode: &Mode, at: &Zoned) -> String {
988    let stamp = at.timestamp().as_second();
989    let open = mode.quiet || m.config.windows.is_empty() || window::current(&m.config.windows, at).is_some();
990    let opens = if open { 0 } else { window::next_opening(&m.config.windows, at).map_or(-1, |z| z.timestamp().as_second()) };
991    format!("{}|{open}|{opens}|{}|{}", mode.time.id(), m.overrides.porch_rests(stamp), at.date())
992}
993
994/// The card, from now to the end of tomorrow, with no weather, reminder,
995/// call or line left on where you stopped.
996#[cfg(test)]
997fn snapshot(m: &Moment, porch: Option<&PorchInput>, plan: Option<&PlanInput>, agenda: Option<&AgendaInput>, doses: &[Dose]) -> Snapshot {
998    snapshot_with(m, porch, plan, agenda, doses, &More::default())
999}
1000
1001/// The card, from now to the end of tomorrow.
1002fn snapshot_with(m: &Moment, porch: Option<&PorchInput>, plan: Option<&PlanInput>, agenda: Option<&AgendaInput>, doses: &[Dose], more: &More) -> Snapshot {
1003    let set_up = m.config.every_account().next().is_some();
1004    let mut made: BTreeMap<String, PorchMade> = BTreeMap::new();
1005    // Each message written once, its place given to every frame that lists it.
1006    let mut mail: Vec<Mail> = Vec::new();
1007    let mut placed: BTreeMap<String, usize> = BTreeMap::new();
1008    let mut place = |message: &Mail| -> usize {
1009        if let Some(at) = placed.get(&message.key).filter(|_| !message.key.is_empty()) {
1010            return *at;
1011        }
1012        mail.push(message.clone());
1013        placed.insert(message.key.clone(), mail.len() - 1);
1014        mail.len() - 1
1015    };
1016    // Codes and doses as the matrix of what reaches you lets them come at each time.
1017    use sioul_core::attention::{Event, Kind as Own, Level, Now, Row};
1018    let attention = sioul_core::attention::Attention::of(m.config);
1019    let comes = |kind: Own, mode: &Mode| attention.level(Row::Own(kind), &Now::of(mode)) == Level::Now;
1020    // A reminder shows when its row lets it come or be seen then: not while it
1021    // waits for a later time (sleep, a pause, free time, work's while work rests).
1022    let reminders = reminder_lines(m.tr, more.reminders, m.details, m.now.timestamp().as_second());
1023    let shows = |work: bool, mode: &Mode| !matches!(attention.decide(&Event::own(Own::Dates).for_area(work.then_some(Area::WORK)), &Now::of(mode)).level, Level::Later | Level::Never);
1024    // Each call's line written once, in the Porch's words as seen from each frame's start.
1025    let mut calls: Vec<String> = Vec::new();
1026    let mut listed_calls: BTreeMap<String, Vec<String>> = BTreeMap::new();
1027    let mut call_at = |mode: &Mode, from: &Zoned| -> Vec<usize> {
1028        let Some(lines_at) = more.calls else { return Vec::new() };
1029        if mode.paused() {
1030            return Vec::new();
1031        }
1032        let lines = listed_calls.entry(format!("{}|{}|{}", kind(mode), mode.time.id(), from.date())).or_insert_with(|| lines_at(&Now::of(mode), from)).clone();
1033        let lines = if m.details || lines.is_empty() { lines } else { vec![m.tr.text("home-card-calls-plain", None)] };
1034        lines
1035            .into_iter()
1036            .map(|line| match calls.iter().position(|c| *c == line) {
1037                Some(at) => at,
1038                None => {
1039                    calls.push(line);
1040                    calls.len() - 1
1041                }
1042            })
1043            .collect()
1044    };
1045    let frames: Vec<Frame> = timeline(m)
1046        .into_iter()
1047        .enumerate()
1048        .map(|(index, (from, until, mode))| {
1049            // Asleep, in a pause or in free time, the Porch says nothing on the card.
1050            let rests = mode.sleeps() || mode.paused() || mode.free();
1051            let porch = if rests {
1052                None
1053            } else if !set_up {
1054                Some(PorchMade::said(m.tr.text("home-card-setup", None)))
1055            } else {
1056                porch.map(|p| made.entry(porch_key(m, &mode, &from)).or_insert_with(|| porch_at(m, p, &mode, &from)).clone())
1057            };
1058            let kind = kind(&mode);
1059            let step = plan.and_then(|p| step_at(m, p, &mode, &from));
1060            Frame {
1061                from: ms(&from),
1062                until: ms(&until),
1063                kind: kind.to_string(),
1064                status: status(m, &mode, &from),
1065                dnd: m.dnd.map(|d| d.line_in(kind, ms(&from), index == 0)).unwrap_or_default(),
1066                porch: porch.map(|p| PorchLines { line: p.line, mail: p.mail.iter().map(&mut place).collect() }),
1067                // The pause's card shows the pause alone: a code or a dose
1068                // then comes by its own notification, as its row says.
1069                codes: !mode.paused() && comes(Own::Codes, &mode),
1070                doses: !mode.paused() && comes(Own::Doses, &mode),
1071                events: !mode.paused(),
1072                reminders: reminders.iter().enumerate().filter(|(_, (_, work))| !mode.paused() && shows(*work, &mode)).map(|(at, _)| at).collect(),
1073                calls: call_at(&mode, &from),
1074                overlaps: !rests,
1075                stopped: !rests && time_for_a_step(&mode),
1076                note: if set_up || rests { String::new() } else { m.tr.text("home-card-setup", None) },
1077                step,
1078            }
1079        })
1080        .collect();
1081    let made = ms(m.now);
1082    let end = frames.last().map_or(made, |f| f.until);
1083    let stopped = more.stopped.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(|text| if m.details { said(m.tr, "home-card-stopped", &[("text", text.to_string())]) } else { m.tr.text("home-card-stopped-plain", None) });
1084    Snapshot {
1085        v: VERSION,
1086        made,
1087        details: m.details,
1088        stale_after: made + FRESH_MS,
1089        stale: labels(m.tr, m.now),
1090        beyond: m.tr.text("home-card-beyond", None),
1091        words: Words { mail: m.tr.text("home-card-mail", None), agenda: m.tr.text("home-card-agenda", None) },
1092        today: today_lines(m, &more.weather, end),
1093        frames,
1094        mail,
1095        agenda: agenda.and_then(|a| agenda_lines(m, a)),
1096        codes: porch.map(|p| codes(m, p)).unwrap_or_default(),
1097        doses: dose_lines(m.tr, doses, m.details),
1098        reminders: reminders.into_iter().map(|(line, _)| line).collect(),
1099        calls,
1100        overlaps: agenda.map(|a| overlap_lines(m, a)).unwrap_or_default(),
1101        stopped: stopped.unwrap_or_default(),
1102    }
1103}
1104
1105/// What a card says, its age aside: written again unchanged, Android need not draw it again.
1106fn content(snapshot: &Snapshot) -> String {
1107    let mut same = snapshot.clone();
1108    same.made = 0;
1109    same.stale_after = 0;
1110    same.stale.clear();
1111    if let Some(first) = same.frames.first_mut() {
1112        first.from = 0;
1113    }
1114    serde_json::to_string(&same).unwrap_or_default()
1115}
1116
1117// ---------------------------------------------------------------- written, and drawn
1118
1119/// Written on Android; on a computer only when SIOUL_HOME_CARD is set.
1120fn on() -> bool {
1121    cfg!(target_os = "android") || std::env::var_os("SIOUL_HOME_CARD").is_some()
1122}
1123
1124fn path() -> PathBuf {
1125    sioul_core::config::state_dir().join(FILE)
1126}
1127
1128/// The details are on unless "Details on the home screen" was unticked on this device.
1129pub(crate) fn details() -> bool {
1130    !crate::work::WorkState::load().flags.get(PLAIN).copied().unwrap_or(false)
1131}
1132
1133/// One writing at a time: the window's and a dose's alarm's.
1134static WRITING: Mutex<()> = Mutex::new(());
1135
1136/// The card's text in place of the one before, whole (written beside, then renamed).
1137fn write(text: &str) -> bool {
1138    let path = path();
1139    if let Some(parent) = path.parent() {
1140        let _ = std::fs::create_dir_all(parent);
1141    }
1142    let beside = path.with_extension("json.new");
1143    std::fs::write(&beside, text).and_then(|()| std::fs::rename(&beside, &path)).is_ok()
1144}
1145
1146/// Android asked to draw the card again: an explicit broadcast to Sioul's
1147/// own receiver (android/main.cpp, the call the sync apps are asked with).
1148fn draw() {
1149    #[cfg(target_os = "android")]
1150    // SAFETY: three zero-terminated texts, valid for the call.
1151    unsafe {
1152        crate::backend::sioul_android_broadcast(c"com.aurelienpierre.sioul".as_ptr(), c"com.aurelienpierre.sioul.HomeCard".as_ptr(), c"com.aurelienpierre.sioul.action.HOME_CARD".as_ptr())
1153    };
1154}
1155
1156/// What the card is made from, as the window last had it.
1157static PORCH: Mutex<Option<Arc<PorchInput>>> = Mutex::new(None);
1158static PLAN: Mutex<Option<Arc<PlanInput>>> = Mutex::new(None);
1159
1160/// The card last written: what it says, when, and when its first frame ends (Unix seconds).
1161struct Written {
1162    content: String,
1163    made: i64,
1164    first_ends: i64,
1165}
1166
1167static WRITTEN: Mutex<Option<Written>> = Mutex::new(None);
1168
1169/// The Porch's messages the window just gathered, before the hours sort
1170/// them (`backend::compute`): the card follows.
1171pub(crate) fn porch_seen(items: &[Triaged], senders: &Senders, store: Option<&ProjectStore>) {
1172    if !on() {
1173        return;
1174    }
1175    *PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(PorchInput { items: items.to_vec(), senders: senders.clone(), store: store.cloned() }));
1176    soon(false);
1177}
1178
1179/// The plan the window just made (`work::show_work`): the card follows.
1180pub(crate) fn plan_seen(loaded: &Arc<Loaded>, plan: &Plan, today: &Today, spent: &BTreeMap<String, u32>, stopped: &BTreeMap<String, String>) {
1181    if !on() {
1182        return;
1183    }
1184    *PLAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(PlanInput { loaded: Arc::clone(loaded), plan: plan.clone(), today: today.clone(), spent: spent.clone(), stopped: stopped.clone() }));
1185    soon(false);
1186}
1187
1188/// The events as last read for the card, and what the window hides of them.
1189static AGENDA: Mutex<Option<Arc<AgendaInput>>> = Mutex::new(None);
1190/// Removed or skipped in the window, waiting for "Undo" to pass: left out.
1191type Hidden = (BTreeSet<PathBuf>, BTreeSet<(PathBuf, i64)>);
1192static HIDDEN: Mutex<Option<Hidden>> = Mutex::new(None);
1193/// Read the calendars again before the next card: an event changed.
1194static AGENDA_AGAIN: AtomicBool = AtomicBool::new(true);
1195
1196/// The agenda read again by the window (`pim::show_pim`: a sync, an event
1197/// made, moved, removed or undone), with what it hides meanwhile: the card
1198/// reads the calendars again, and follows.
1199pub(crate) fn agenda_seen(removed: &BTreeSet<PathBuf>, skipped: &BTreeSet<(PathBuf, i64)>) {
1200    if !on() {
1201        return;
1202    }
1203    *HIDDEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some((removed.clone(), skipped.clone()));
1204    AGENDA_AGAIN.store(true, Ordering::SeqCst);
1205    soon(false);
1206}
1207
1208/// The events of today and the month to come, read from the calendars once,
1209/// then again when the window read the agenda again or the day changed.
1210fn agenda_input(now: &Zoned) -> Option<Arc<AgendaInput>> {
1211    let mut kept = AGENDA.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1212    let again = AGENDA_AGAIN.swap(false, Ordering::SeqCst);
1213    if !again && let Some(input) = kept.as_ref().filter(|a| a.read_on == now.date()) {
1214        return Some(Arc::clone(input));
1215    }
1216    let midnight = now.date().to_zoned(now.time_zone().clone()).ok()?.timestamp().as_second();
1217    let reach = midnight + (HORIZON_DAYS + 1) * 86_400 + 3_600;
1218    let (removed, skipped) = HIDDEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone().unwrap_or_default();
1219    let events = sioul_core::agenda::occurrences(midnight, reach)
1220        .into_iter()
1221        .filter(|e| !removed.contains(std::path::Path::new(&e.key)) && !skipped.contains(&(PathBuf::from(&e.key), e.start)))
1222        .collect();
1223    let calendars = !sioul_core::vdir::collections(sioul_core::vdir::Kind::Calendars).is_empty();
1224    let aside = sioul_core::overlaps::SetAside::load(&sioul_core::overlaps::SetAside::default_path()).keys;
1225    let input = Arc::new(AgendaInput { events, calendars, read_on: now.date(), aside });
1226    *kept = Some(Arc::clone(&input));
1227    Some(input)
1228}
1229
1230/// The reminders as last gathered (`remind::tick`, each minute), the events' left out.
1231static REMINDED: Mutex<Option<Arc<Vec<Reminder>>>> = Mutex::new(None);
1232
1233/// The reminders the window just gathered, told or not (`reminders::gather`):
1234/// the card follows when those it may say changed.
1235pub(crate) fn reminders_seen(all: &[Reminder]) {
1236    if !on() {
1237        return;
1238    }
1239    use sioul_core::reminders::Kind;
1240    let kept: Vec<Reminder> = all.iter().filter(|r| !matches!(r.kind, Kind::Event | Kind::Before | Kind::Alarm)).cloned().collect();
1241    let mut seen = REMINDED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1242    if seen.as_deref() == Some(&kept) {
1243        return;
1244    }
1245    *seen = Some(Arc::new(kept));
1246    drop(seen);
1247    soon(false);
1248}
1249
1250/// A forecast fetched, or the place changed (`backend::update_weather`): the card follows.
1251pub(crate) fn weather_seen() {
1252    if on() {
1253        soon(false);
1254    }
1255}
1256
1257/// The weather as the card says it: the place chosen, the forecast kept.
1258fn weather_input() -> WeatherInput {
1259    let config = load_config();
1260    let place = config.weather.place.as_deref().is_some_and(|p| !p.trim().is_empty()) && config.weather.latitude.is_some() && config.weather.longitude.is_some();
1261    let forecast = place.then(|| std::fs::read_to_string(crate::backend::weather_cache()).ok().and_then(|t| serde_json::from_str(&t).ok())).flatten();
1262    WeatherInput { place, forecast }
1263}
1264
1265/// The window's minute: the card written again when its first frame ended,
1266/// or five minutes went by (its age said afresh).
1267pub(crate) fn tick() {
1268    if !on() {
1269        return;
1270    }
1271    let now = Timestamp::now().as_second();
1272    let due = WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().is_none_or(|w| now >= w.first_ends || now - w.made >= REWRITE_EVERY);
1273    if due {
1274        soon(false);
1275    }
1276}
1277
1278/// Sioul put away, or a setting of the card changed: written now, and drawn.
1279pub(crate) fn now() {
1280    if on() {
1281        soon(true);
1282    }
1283}
1284
1285/// The shared do-not-disturb's moment, as last seen in this process.
1286static DND: Mutex<Option<serde_json::Value>> = Mutex::new(None);
1287
1288/// The do-not-disturb on every device, as its module reads it
1289/// (`everywhere::moment`: {on, why, until, until_at, line…}), handed after
1290/// each of its changes, here or from another device, and when first read in
1291/// a process. The same as last time: nothing. With the window's Porch and
1292/// plan, the card is made again; without (a receiver alone), the card on disk
1293/// gets its do-not-disturb lines alone, the rest as Sioul last saw it.
1294pub(crate) fn dnd_seen(moment: serde_json::Value) {
1295    if !on() {
1296        return;
1297    }
1298    {
1299        let mut kept = DND.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1300        if kept.as_ref() == Some(&moment) {
1301            return;
1302        }
1303        *kept = Some(moment.clone());
1304    }
1305    let window = PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_some() && PLAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_some();
1306    if window {
1307        soon(true);
1308        return;
1309    }
1310    let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1311    let Some(mut card) = std::fs::read_to_string(path()).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()).filter(serde_json::Value::is_object) else { return };
1312    if patch_dnd(&mut card, Dnd::of(&moment).as_ref(), Timestamp::now().as_millisecond()) && write(&card.to_string()) {
1313        draw();
1314    }
1315}
1316
1317/// The do-not-disturb lines of a card written before, said again: each
1318/// frame from now on as `Dnd::line_in` says it, a frame cut where it ends
1319/// (Unix ms `now`). Whether anything changed.
1320fn patch_dnd(card: &mut serde_json::Value, dnd: Option<&Dnd>, now: i64) -> bool {
1321    let Some(frames) = frames_of(card) else { return false };
1322    let before = frames.clone();
1323    // Cut the frame its end falls in: its line goes there.
1324    if let Some(end) = dnd.and_then(|d| d.until).filter(|u| *u > 0).map(|u| u * 1000)
1325        && let Some(at) = frames.iter().position(|f| f["from"].as_i64().is_some_and(|from| from < end) && f["until"].as_i64().is_some_and(|until| end < until))
1326    {
1327        let mut after = frames[at].clone();
1328        after["from"] = end.into();
1329        frames[at]["until"] = end.into();
1330        frames.insert(at + 1, after);
1331    }
1332    // The frame for now: the one now falls in, else the first (the clock set back).
1333    let current = frames.iter().position(|f| f["from"].as_i64().unwrap_or(0) <= now && now < f["until"].as_i64().unwrap_or(0)).unwrap_or(0);
1334    for (index, frame) in frames.iter_mut().enumerate() {
1335        let (kind, from) = (frame["kind"].as_str().unwrap_or_default().to_string(), frame["from"].as_i64().unwrap_or(0));
1336        frame["dnd"] = dnd.map(|d| d.line_in(&kind, from, index == current)).unwrap_or_default().into();
1337    }
1338    *frames != before
1339}
1340
1341/// A written card's frames: "times" from version 3, "frames" before.
1342fn frames_of(card: &mut serde_json::Value) -> Option<&mut Vec<serde_json::Value>> {
1343    let key = if card["v"].as_u64().is_some_and(|v| v >= 3) { "times" } else { "frames" };
1344    card[key].as_array_mut()
1345}
1346
1347/// Asked again while being made: once more after. Drawn when asked so once.
1348static AGAIN: AtomicBool = AtomicBool::new(false);
1349static RUNNING: AtomicBool = AtomicBool::new(false);
1350static TELL: AtomicBool = AtomicBool::new(false);
1351
1352/// The card made on a thread of its own, several asks in a row making it once.
1353fn soon(tell: bool) {
1354    if tell {
1355        TELL.store(true, Ordering::SeqCst);
1356    }
1357    AGAIN.store(true, Ordering::SeqCst);
1358    if RUNNING.swap(true, Ordering::SeqCst) {
1359        return;
1360    }
1361    std::thread::spawn(|| {
1362        loop {
1363            while AGAIN.swap(false, Ordering::SeqCst) {
1364                let tell = TELL.swap(false, Ordering::SeqCst);
1365                // A panic on bad data leaves the next card free to be made.
1366                let _ = std::panic::catch_unwind(|| refresh(tell));
1367            }
1368            RUNNING.store(false, Ordering::SeqCst);
1369            if !AGAIN.load(Ordering::SeqCst) || RUNNING.swap(true, Ordering::SeqCst) {
1370                break;
1371            }
1372        }
1373    });
1374}
1375
1376/// The card made from what the window last had, written, and drawn when it
1377/// changed (or `tell`). Not before the window has both the Porch and the
1378/// plan in this process: a card made without them would lose its mail and
1379/// its step until Sioul comes back (put away at once after it opened, its
1380/// pages wait); the card written before stays, its age said.
1381fn refresh(tell: bool) {
1382    let porch = PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
1383    let plan = PLAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
1384    let (Some(porch), Some(plan)) = (porch, plan) else { return };
1385    let now = Zoned::now();
1386    let config = load_config();
1387    let overrides = Overrides::load(&Overrides::default_path());
1388    let blocks = crate::hours::blocks(&now);
1389    let details = details();
1390    let doses = if details { crate::health::card_doses(&now) } else { Vec::new() };
1391    let dnd = DND.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(Dnd::of);
1392    let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: tr(), details, dnd: dnd.as_ref() };
1393    let agenda = agenda_input(&now);
1394    let reminded = REMINDED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone().unwrap_or_default();
1395    let calls = crate::calls::for_card();
1396    let stopped = sioul_core::stopped::Stopped::load(&sioul_core::stopped::Stopped::default_path()).map(|s| s.text);
1397    let more = More { weather: weather_input(), reminders: &reminded, calls: calls.as_deref(), stopped };
1398    let card = snapshot_with(&moment, Some(&porch), Some(&plan), agenda.as_deref(), &doses, &more);
1399    let said = content(&card);
1400    let Ok(text) = serde_json::to_string(&card) else { return };
1401    let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1402    let mut written = WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1403    let changed = written.as_ref().is_none_or(|w| w.content != said);
1404    if !write(&text) {
1405        return;
1406    }
1407    let first_ends = card.frames.first().map_or(0, |f| f.until / 1000);
1408    *written = Some(Written { content: said, made: now.timestamp().as_second(), first_ends });
1409    drop(written);
1410    if changed || tell {
1411        draw();
1412    }
1413}
1414
1415/// A dose marked or reminded outside the window (a dose's alarm, its "Taken":
1416/// `alarms`): the card's doses written again, the rest as Sioul last saw it.
1417/// No card yet, nothing.
1418pub(crate) fn doses_changed() {
1419    if !on() {
1420        return;
1421    }
1422    let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1423    let Some(mut card) = std::fs::read_to_string(path()).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()).filter(serde_json::Value::is_object) else { return };
1424    let details = details();
1425    let now = Zoned::now();
1426    let doses = if details { crate::health::card_doses(&now) } else { Vec::new() };
1427    let Ok(lines) = serde_json::to_value(dose_lines(tr(), &doses, details)) else { return };
1428    if card["doses"] == lines {
1429        return;
1430    }
1431    card["doses"] = lines;
1432    if write(&card.to_string()) {
1433        draw();
1434    }
1435}
1436
1437/// New mail fetched outside the window (the phone's background service,
1438/// `steps`, while Sioul is closed or frozen): the card's messages and codes
1439/// made again from the Porch as it is now, the rest (its lines, the step, the
1440/// doses, the agenda) as Sioul last saw it. In the window's process the
1441/// window's own Porch follows (`porch_seen`): nothing here. No card yet, nothing.
1442pub(crate) fn mail_came() {
1443    if !on() || PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_some() {
1444        return;
1445    }
1446    let config = load_config();
1447    let now = Zoned::now();
1448    // The Porch as the window gathers it (`backend::World`): the projects and their ties, the senders let in.
1449    let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
1450    let store = config.notes_root_path().and_then(|root| ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
1451    let known = porch::KnownSenders::load(&config.known_senders_path());
1452    let senders = Senders::load(&config);
1453    let state = sioul_core::state::PorchState::load(&sioul_core::state::PorchState::default_path());
1454    let items = porch::gather(&config.mail_sources(), store.as_ref(), &known, &senders, &state, now.timestamp().as_second());
1455    let input = PorchInput { items, senders, store };
1456    let overrides = Overrides::load(&Overrides::default_path());
1457    let blocks = crate::hours::blocks(&now);
1458    let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: tr(), details: details(), dnd: None };
1459    let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1460    let Some(mut card) = std::fs::read_to_string(path()).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()).filter(serde_json::Value::is_object) else { return };
1461    if patch_mail(&mut card, &moment, &input) && write(&card.to_string()) {
1462        draw();
1463    }
1464}
1465
1466/// The messages and codes of a card written before, made again from `input`:
1467/// each frame's Porch as `snapshot` makes it at the frame's start; the frames
1468/// that say nothing of the Porch (asleep, a pause, free time) left so. Whether
1469/// anything changed.
1470fn patch_mail(card: &mut serde_json::Value, m: &Moment, input: &PorchInput) -> bool {
1471    if card["v"] != VERSION {
1472        return false;
1473    }
1474    let before = card.clone();
1475    let hours = m.config.week_hours();
1476    let zone = m.now.time_zone().clone();
1477    let mut made: BTreeMap<String, PorchMade> = BTreeMap::new();
1478    let mut mail: Vec<Mail> = Vec::new();
1479    let mut placed: BTreeMap<String, usize> = BTreeMap::new();
1480    let Some(frames) = frames_of(card) else { return false };
1481    for frame in frames.iter_mut().filter(|f| f["porch"].is_object()) {
1482        let Some(from) = frame["from"].as_i64().and_then(|at| Timestamp::from_millisecond(at).ok()).map(|t| t.to_zoned(zone.clone())) else { continue };
1483        let mode = quiet::mode(&hours, &m.config.time_off, m.overrides, m.blocks, &from);
1484        let porch = made.entry(porch_key(m, &mode, &from)).or_insert_with(|| porch_at(m, input, &mode, &from)).clone();
1485        let at: Vec<usize> = porch
1486            .mail
1487            .iter()
1488            .map(|message| match placed.get(&message.key).filter(|_| !message.key.is_empty()) {
1489                Some(at) => *at,
1490                None => {
1491                    mail.push(message.clone());
1492                    placed.insert(message.key.clone(), mail.len() - 1);
1493                    mail.len() - 1
1494                }
1495            })
1496            .collect();
1497        let Ok(lines) = serde_json::to_value(PorchLines { line: porch.line, mail: at }) else { return false };
1498        frame["porch"] = lines;
1499    }
1500    let (Ok(mail), Ok(codes)) = (serde_json::to_value(mail), serde_json::to_value(codes(m, input))) else { return false };
1501    card["mail"] = mail;
1502    card["codes"] = codes;
1503    *card != before
1504}
1505
1506/// A tap on the card, kept by Java (HomeCardOpener.java) and taken once, as
1507/// main.qml's `openThing` takes it {kind, uri, key}: the Porch; a message, on
1508/// the Porch in its Reader ("card-mail", its file); Now on a step ("now", the
1509/// task's address); the Agenda; an event in it ("event", its file); what a
1510/// reminder is about (its task, budget, paper or contract). One older than
1511/// five minutes is let go.
1512pub(crate) fn opened() -> Option<(&'static str, String, String)> {
1513    let path = sioul_core::config::state_dir().join(OPENED);
1514    let text = std::fs::read_to_string(&path).ok()?;
1515    let _ = std::fs::remove_file(&path);
1516    tap(&text, Timestamp::now().as_millisecond())
1517}
1518
1519/// A tap as Java writes it: {"open": "porch" | "now" | "mail" | "agenda" |
1520/// "event" | "reminder", "uid", "key", "at" (Unix ms)}. A message or an event
1521/// without its file opens its page; a reminder, what it is about ("key": its
1522/// address, `sioul:task/…`), else the Porch. The file is the window's to
1523/// check, as for any link.
1524fn tap(text: &str, now: i64) -> Option<(&'static str, String, String)> {
1525    let tap: serde_json::Value = serde_json::from_str(text).ok()?;
1526    let at = tap["at"].as_i64()?;
1527    if now - at > TAP_KEPT_MS || at - now > 60_000 {
1528        return None;
1529    }
1530    let key = tap["key"].as_str().unwrap_or_default().to_string();
1531    match tap["open"].as_str()? {
1532        "porch" => Some(("porch", String::new(), String::new())),
1533        "now" => Some(("now", tap["uid"].as_str().filter(|u| !u.is_empty()).map(sioul_core::links::task_uri).unwrap_or_default(), String::new())),
1534        "mail" if !key.is_empty() => Some(("card-mail", String::new(), key)),
1535        "mail" => Some(("porch", String::new(), String::new())),
1536        "event" if !key.is_empty() => Some(("event", String::new(), key)),
1537        "event" | "agenda" => Some(("agenda", String::new(), String::new())),
1538        "reminder" => Some(reminder_opens(&key)),
1539        _ => None,
1540    }
1541}
1542
1543/// What a reminder's tap opens, as a reminder's notification does on a
1544/// computer (`remind::tick`): its task, its budget, its paper, its contract;
1545/// anything else, the Porch.
1546fn reminder_opens(target: &str) -> (&'static str, String, String) {
1547    if let Some(uid) = target.strip_prefix("sioul:task/") {
1548        ("task", target.to_string(), uid.to_string())
1549    } else if target.starts_with("sioul:budget/") {
1550        ("budget", target.to_string(), String::new())
1551    } else if let Some(id) = target.strip_prefix("sioul:paper/") {
1552        ("paper", target.to_string(), id.to_string())
1553    } else if let Some(id) = target.strip_prefix("sioul:contract/") {
1554        ("contract", target.to_string(), id.to_string())
1555    } else {
1556        ("porch", String::new(), String::new())
1557    }
1558}
1559
1560/// "Details on the home screen", for Settings (on a phone): its row, in `group` and `section`.
1561pub(crate) fn setting(group: &str, section: &str, on: bool) -> sioul_core::settings::Setting {
1562    sioul_core::settings::Setting {
1563        key: "home_card_details".into(),
1564        kind: sioul_core::settings::Kind::Bool,
1565        label: tr().text("set-home-card-details", None),
1566        help: tr().text("set-home-card-details-help", None),
1567        value: sioul_core::config::SettingValue::Bool(on),
1568        choices: Vec::new(),
1569        rows: Vec::new(),
1570        min: 0.0,
1571        max: 0.0,
1572        step: 1.0,
1573        unit: String::new(),
1574        group: group.to_string(),
1575        section: section.to_string(),
1576    }
1577}
1578
1579/// Whether the setting shows: on a phone (or with SIOUL_HOME_CARD set).
1580pub(crate) fn has_setting() -> bool {
1581    on()
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586    use super::*;
1587    use sioul_core::card::Card;
1588    use sioul_core::codes::OneTimeCode;
1589    use sioul_core::config::Priority;
1590    use sioul_core::needs::{Days, Needs};
1591    use sioul_core::plan::Settings;
1592    use sioul_core::reminders;
1593    use sioul_core::tasks::Task;
1594
1595    /// Monday to Friday 09:00–17:00 at work, Tuesday 17:30–18:30 for admin.
1596    fn config() -> Config {
1597        toml::from_str(
1598            "[[account]]\nid = \"me\"\naddress = \"me@example.org\"\n\n\
1599             [[window]]\nday = \"monday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1600             [[window]]\nday = \"tuesday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1601             [[window]]\nday = \"wednesday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1602             [[window]]\nday = \"thursday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1603             [[window]]\nday = \"friday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1604             [[window]]\nday = \"tuesday\"\nstart = \"17:30\"\nend = \"18:30\"\nkind = \"admin\"\n",
1605        )
1606        .unwrap()
1607    }
1608
1609    /// Three meals and the night (bed 23:00, winding down from 22:00, waking 07:00).
1610    fn blocks(now: &Zoned) -> Blocks {
1611        let needs = Needs { meals_on: true, sleep_on: true, ..Needs::default() };
1612        Blocks::of(&needs, &Days::default(), &[], now)
1613    }
1614
1615    fn at(text: &str) -> Zoned {
1616        text.parse().unwrap()
1617    }
1618
1619    /// A message come to the account "me", not read yet (in `new/`), its file named after its sender and subject.
1620    fn message(from: &str, subject: &str, lane: Lane) -> Triaged {
1621        let raw = format!("From: {from}\r\nSubject: {subject}\r\nDate: Mon, 05 Oct 2026 08:00:00 +0200\r\n\r\nHello,\r\n\r\n  the text   on several\r\nlines.\r\n");
1622        let mut card = Card::from_bytes(raw.as_bytes()).unwrap();
1623        let name: String = format!("{from}-{subject}").chars().filter(char::is_ascii_alphanumeric).collect();
1624        card.path = Some(PathBuf::from(format!("/mail/me/INBOX/new/1759600000.{name}.sioul")));
1625        card.account = Some("me".into());
1626        Triaged { card, lane, trust: Trust::Verified, code: None, reasons: Vec::new(), priority: Priority::Average, checks: None, assessment: None }
1627    }
1628
1629    /// The same, come at `sent` (Unix seconds), read already when `read` (its file in `cur/`, flagged S).
1630    fn sent_at(from: &str, subject: &str, sent: i64, read: bool) -> Triaged {
1631        let mut letter = message(from, subject, Lane::People);
1632        letter.card.date = Some(sent);
1633        if read {
1634            let name = letter.card.path.as_ref().unwrap().file_name().unwrap().to_string_lossy().to_string();
1635            letter.card.path = Some(PathBuf::from(format!("/mail/me/INBOX/cur/{name}:2,S")));
1636        }
1637        letter
1638    }
1639
1640    /// Four letters from people you know (safe senders: in every time but sleep), one code.
1641    fn porch_input(now: &Zoned) -> PorchInput {
1642        let mut items: Vec<Triaged> = [("Marie Dupont <marie@example.org>", "Dinner on Saturday"), ("Paul <paul@example.org>", "The photos"), ("Léa <lea@example.org>", "Sunday"), ("Tom <tom@example.org>", "A question")]
1643            .into_iter()
1644            .map(|(from, subject)| message(from, subject, Lane::People))
1645            .collect();
1646        let mut code = message("La Banque <no-reply@banque.example>", "Votre code", Lane::RightNow);
1647        code.code = Some(OneTimeCode { kind: CodeKind::Code, code: Some("482913".into()), expires_minutes: Some(10) });
1648        // Sent two minutes ago.
1649        code.card.date = Some(now.timestamp().as_second() - 120);
1650        items.push(code);
1651        // People you know, marked safe: their mail comes at every time but sleep (the usual matrix).
1652        let senders = Senders { safe: sioul_core::porch::SenderList::parse("marie@example.org\npaul@example.org\nlea@example.org\ntom@example.org\n"), ..Senders::default() };
1653        PorchInput { items, senders, store: None }
1654    }
1655
1656    fn task(uid: &str, title: &str) -> Task {
1657        Task { uid: uid.into(), title: title.into(), estimate: 15, ..Task::default() }
1658    }
1659
1660    fn plan_input(now: &Zoned) -> PlanInput {
1661        let mut form = task("form", "Call the CAF about the housing aid");
1662        form.due = "2026-10-30".into();
1663        // For work: work's hours bring it, admin's (they are set) do not.
1664        form.area = "work".into();
1665        let tasks = vec![form];
1666        let plan = sioul_core::plan::plan(&tasks, now.date(), &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
1667        PlanInput { loaded: Arc::new(Loaded { tasks, ..Loaded::default() }), plan, today: Today { date: now.date().to_string(), ..Today::default() }, spent: BTreeMap::new(), stopped: BTreeMap::new() }
1668    }
1669
1670    struct Made {
1671        card: Snapshot,
1672    }
1673
1674    impl Made {
1675        /// The frame showing at `when`.
1676        fn at(&self, when: &str) -> &Frame {
1677            let t = at(when).timestamp().as_millisecond();
1678            self.card.frames.iter().find(|f| f.from <= t && t < f.until).unwrap_or_else(|| panic!("no frame at {when}: {:#?}", self.card.frames))
1679        }
1680    }
1681
1682    /// The calendars' events, as the card reads them (`agenda_input`), from
1683    /// `(title, start, end, all day, colour, cancelled)` in Paris; one
1684    /// calendar, "Personal".
1685    fn events_of(events: &[(&str, &str, &str, bool, Option<&str>, bool)]) -> AgendaInput {
1686        let events = events
1687            .iter()
1688            .enumerate()
1689            .map(|(n, (title, start, end, all_day, color, cancelled))| Occurrence {
1690                key: format!("/calendars/me/personal/event-{n}.ics"),
1691                summary: title.to_string(),
1692                start: at(start).timestamp().as_second(),
1693                end: at(end).timestamp().as_second(),
1694                all_day: *all_day,
1695                calendar: "Personal".into(),
1696                color: color.map(str::to_string),
1697                cancelled: *cancelled,
1698                ..Occurrence::default()
1699            })
1700            .collect();
1701        AgendaInput { events, calendars: true, read_on: Date::constant(2026, 10, 5), aside: BTreeSet::new() }
1702    }
1703
1704    /// A dentist tomorrow, a whole day on Monday next week, a trip over three days, a call that was cancelled.
1705    fn some_events() -> AgendaInput {
1706        events_of(&[
1707            ("Dentist", "2026-10-06T11:00[Europe/Paris]", "2026-10-06T12:00[Europe/Paris]", false, Some("#4C6B5CFF"), false),
1708            ("Choir", "2026-10-05T19:00[Europe/Paris]", "2026-10-05T20:30[Europe/Paris]", false, None, false),
1709            ("Neighbours' party", "2026-10-12T00:00[Europe/Paris]", "2026-10-13T00:00[Europe/Paris]", true, Some("#b07d3f"), false),
1710            ("Trip to the sea", "2026-10-14T00:00[Europe/Paris]", "2026-10-17T00:00[Europe/Paris]", true, None, false),
1711            ("Night train", "2026-10-20T22:30[Europe/Paris]", "2026-10-21T06:45[Europe/Paris]", false, None, false),
1712            ("A call", "2026-10-07T09:00[Europe/Paris]", "2026-10-07T09:30[Europe/Paris]", false, None, true),
1713        ])
1714    }
1715
1716    fn make(now: &str, overrides: &Overrides, details: bool, language: &str, doses: &[Dose]) -> Made {
1717        let now = at(now);
1718        let config = config();
1719        let tr = Translator::new(language);
1720        let blocks = blocks(&now);
1721        let moment = Moment { now: &now, config: &config, overrides, blocks: &blocks, tr: &tr, details, dnd: None };
1722        let porch = porch_input(&now);
1723        let plan = plan_input(&now);
1724        Made { card: snapshot(&moment, Some(&porch), Some(&plan), Some(&some_events()), doses) }
1725    }
1726
1727    impl Made {
1728        /// The messages a frame lists: their senders.
1729        fn senders(&self, frame: &Frame) -> Vec<&str> {
1730            frame.porch.iter().flat_map(|p| p.mail.iter()).map(|at| self.card.mail[*at].sender.as_str()).collect()
1731        }
1732    }
1733
1734    /// Every word the card can show in `frame`, in one text: its lines, its
1735    /// messages, its step, the agenda's days and events.
1736    fn words(card: &Snapshot, frame: &Frame) -> String {
1737        let mut out = vec![frame.status.clone(), frame.note.clone(), card.today.line.clone()];
1738        out.extend(card.today.date.iter().map(|l| l.text.clone()));
1739        if let Some(p) = &frame.porch {
1740            out.push(p.line.clone());
1741            for mail in p.mail.iter().map(|at| &card.mail[*at]) {
1742                out.extend([mail.sender.clone(), mail.subject.clone(), mail.text.clone()]);
1743                out.extend(mail.when.iter().map(|l| l.text.clone()));
1744            }
1745        }
1746        if let Some(s) = &frame.step {
1747            out.extend([s.title.clone(), s.why.clone()]);
1748        }
1749        if let Some(agenda) = card.agenda.as_ref().filter(|_| frame.events) {
1750            out.push(agenda.empty.clone());
1751            out.extend(agenda.days.iter().flat_map(|d| d.label.iter()).map(|l| l.text.clone()));
1752            out.extend(agenda.events.iter().flat_map(|e| [e.title.clone(), e.time.clone()]));
1753        }
1754        out.extend(card.words.mail.lines().chain(card.words.agenda.lines()).map(str::to_string));
1755        out.join("\n")
1756    }
1757
1758    #[test]
1759    fn each_time_says_its_own() {
1760        // Monday 5 October 2026, 10:00 in Paris: work until 12:10 (lunch is got ready from then).
1761        let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "en", &[]);
1762        let work = made.at("2026-10-05T10:00[Europe/Paris]");
1763        assert_eq!((work.kind.as_str(), work.status.as_str()), ("work", "Work until 12:10."));
1764        let porch = work.porch.as_ref().unwrap();
1765        // Four letters and a code came: the letters listed, no sentence above them (the
1766        // code has its own line); come at the same time, in the Porch's order.
1767        assert_eq!(porch.line, "");
1768        assert_eq!(made.senders(work), ["Marie Dupont", "Paul", "Léa", "Tom"]);
1769        let first = &made.card.mail[porch.mail[0]];
1770        assert_eq!((first.subject.as_str(), first.text.as_str()), ("Dinner on Saturday", "Hello, the text on several lines."));
1771        let step = work.step.as_ref().unwrap();
1772        assert_eq!((step.title.as_str(), step.why.as_str(), step.uid.as_str()), ("Call the CAF about the housing aid", "Its date is 30 October: three weeks left.", "form"));
1773        assert!(work.codes && work.doses);
1774        // The code, with its site, as "Right now" says it, until it expires (ten minutes from its sending).
1775        assert_eq!(made.card.codes.len(), 1);
1776        assert_eq!(made.card.codes[0].line, "Code from La Banque (verified): 482913");
1777        assert_eq!(made.card.codes[0].warning, "");
1778        assert_eq!(made.card.codes[0].until, at("2026-10-05T10:08[Europe/Paris]").timestamp().as_millisecond());
1779        // Lunch: its line, no step; the Porch as at meals (people you know are safe: they come).
1780        let meal = made.at("2026-10-05T12:30[Europe/Paris]");
1781        assert_eq!((meal.kind.as_str(), meal.status.as_str()), ("meals", "Meal until 13:00."));
1782        assert!(meal.step.is_none());
1783        // Work again, then leisure in the evening: no step.
1784        assert_eq!(made.at("2026-10-05T15:00[Europe/Paris]").status, "Work until 17:00.");
1785        let evening = made.at("2026-10-05T17:30[Europe/Paris]");
1786        assert_eq!((evening.kind.as_str(), evening.status.as_str()), ("leisure", "Leisure until 19:00: what you enjoy."));
1787        assert!(evening.step.is_none() && evening.porch.is_some());
1788        // Winding down from 22:00, then the night from bedtime: the sleep's line alone, as the window says it;
1789        // doses remind during sleep by default, and codes you asked for come at once asleep too
1790        // (docs/attention.md, Q6; before, they waited on the Porch).
1791        assert_eq!(made.at("2026-10-05T22:15[Europe/Paris]").status, "Winding down: nothing disturbs until tomorrow at 07:00.");
1792        let night = made.at("2026-10-05T23:30[Europe/Paris]");
1793        assert_eq!((night.kind.as_str(), night.status.as_str()), ("sleep", "Sleep: nothing disturbs until tomorrow at 07:00."));
1794        assert!(night.porch.is_none() && night.step.is_none() && night.codes && night.doses);
1795        // Past midnight "tomorrow" is today: a frame ends at each midnight.
1796        assert!(made.card.frames.iter().any(|f| f.from == at("2026-10-06T00:00[Europe/Paris]").timestamp().as_millisecond()));
1797        assert_eq!(made.at("2026-10-06T05:00[Europe/Paris]").status, "Sleep: nothing disturbs until 07:00.");
1798        // Tuesday: work, then admin hours from 17:30, with no step: the CAF is work's.
1799        let admin = made.at("2026-10-06T17:45[Europe/Paris]");
1800        assert_eq!((admin.kind.as_str(), admin.status.as_str()), ("admin", "Admin time until 18:30: offices, bills, letters."));
1801        assert!(admin.step.is_none());
1802        assert!(made.at("2026-10-06T09:30[Europe/Paris]").step.is_some());
1803        // The frames end with tomorrow: Wednesday is not given.
1804        let last = made.card.frames.last().unwrap();
1805        assert_eq!(last.until, at("2026-10-07T00:00[Europe/Paris]").timestamp().as_millisecond());
1806        // In French, with its typography.
1807        let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "fr", &[]);
1808        assert_eq!(fr.at("2026-10-06T05:00[Europe/Paris]").status, "Sommeil\u{202f}: rien ne dérange jusqu’à 07:00.");
1809        assert_eq!(fr.at("2026-10-05T23:30[Europe/Paris]").status, "Sommeil\u{202f}: rien ne dérange jusqu’à demain à 07:00.");
1810        assert_eq!(fr.at("2026-10-05T10:00[Europe/Paris]").status, "Travail jusqu’à 12:10.");
1811    }
1812
1813    #[test]
1814    fn a_pause_says_its_line_and_nothing_else() {
1815        let paused = Overrides { paused_since: Some(at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1816        let made = make("2026-10-05T10:00[Europe/Paris]", &paused, true, "en", &[dose("Magnesium · 300 mg", "09:45", "2026-10-05T09:45[Europe/Paris]", 0)]);
1817        let frame = made.at("2026-10-05T10:00[Europe/Paris]");
1818        assert_eq!((frame.kind.as_str(), frame.status.as_str()), ("paused", "Paused."));
1819        assert!(frame.porch.is_none() && frame.step.is_none() && !frame.codes && !frame.doses && !frame.events);
1820        // The pause lasts until you come back: one frame per day, all paused.
1821        assert!(made.card.frames.iter().all(|f| f.kind == "paused"));
1822        // Free time: its line; codes and doses reach you, as it says; no Porch, no step; the agenda stays.
1823        let free = Overrides { free_since: Some(at("2026-10-05T14:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1824        let made = make("2026-10-05T14:30[Europe/Paris]", &free, true, "en", &[]);
1825        let frame = made.at("2026-10-05T14:30[Europe/Paris]");
1826        assert_eq!(frame.kind, "free-time");
1827        assert_eq!(frame.status, "Free time: only your safe senders, doses and codes reach you. Work comes back when you do.");
1828        assert!(frame.porch.is_none() && frame.step.is_none() && frame.codes && frame.doses && frame.events);
1829        // It ends at the night's start: winding down then.
1830        assert_eq!(made.at("2026-10-05T22:30[Europe/Paris]").kind, "sleep");
1831    }
1832
1833    #[test]
1834    fn the_porch_keeps_its_hours() {
1835        // After a pause the Porch rests until its next hours: no mail named, when it opens.
1836        let rests = Overrides { porch_rests_until: Some(at("2026-10-06T09:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1837        let made = make("2026-10-05T15:00[Europe/Paris]", &rests, true, "en", &[]);
1838        let frame = made.at("2026-10-05T15:00[Europe/Paris]");
1839        let porch = frame.porch.as_ref().unwrap();
1840        assert_eq!(porch.line, "After the pause, the Porch opens tomorrow at 09:00.");
1841        assert!(porch.mail.is_empty());
1842        // The next morning, open again: the letters listed.
1843        assert_eq!(made.at("2026-10-06T09:30[Europe/Paris]").porch.as_ref().unwrap().mail.len(), 4);
1844        // Work now outside the hours: work's time, but the Porch keeps its own (closed until 09:00).
1845        let work_now = Overrides { work_now: Some(at("2026-10-05T20:30[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1846        let made = make("2026-10-05T17:30[Europe/Paris]", &work_now, true, "en", &[]);
1847        let frame = made.at("2026-10-05T17:30[Europe/Paris]");
1848        assert_eq!(frame.status, "Work shown until 20:30, by your choice.");
1849        let porch = frame.porch.as_ref().unwrap();
1850        assert_eq!(porch.line, "The Porch opens tomorrow at 09:00.");
1851        assert!(porch.mail.is_empty(), "never mail waiting for later");
1852        // Dinner comes first, then work again until 20:30.
1853        assert_eq!(made.at("2026-10-05T19:30[Europe/Paris]").kind, "meals");
1854        assert_eq!(made.at("2026-10-05T20:15[Europe/Paris]").status, "Work shown until 20:30, by your choice.");
1855        // Strangers' mail waits for work and admin time: in leisure the Porch names none of it.
1856        let config = config();
1857        let tr = Translator::new("en");
1858        let now = at("2026-10-05T17:30[Europe/Paris]");
1859        let blocks = blocks(&now);
1860        let overrides = Overrides::default();
1861        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
1862        let mut input = porch_input(&now);
1863        input.items = vec![message("A stranger <someone@elsewhere.example>", "Offer", Lane::Screener)];
1864        let card = snapshot(&moment, Some(&input), None, None, &[]);
1865        let evening = card.frames.iter().find(|f| f.kind == "leisure").unwrap();
1866        assert_eq!(evening.porch.as_ref().unwrap().line, "Nothing waits on the Porch.");
1867        assert!(evening.porch.as_ref().unwrap().mail.is_empty());
1868        let work = card.frames.iter().find(|f| f.kind == "work").unwrap();
1869        assert_eq!(card.mail[work.porch.as_ref().unwrap().mail[0]].sender, "A stranger");
1870    }
1871
1872    #[test]
1873    fn mail_is_listed_as_a_mail_card_lists_it() {
1874        let now = at("2026-10-05T10:00[Europe/Paris]");
1875        let stamp = |text: &str| at(text).timestamp().as_second();
1876        let mut config = config();
1877        config.accounts.push(toml::from_str::<Config>("[[account]]\nid = \"club\"\naddress = \"me@club.example\"\n").unwrap().accounts.remove(0));
1878        let tr = Translator::new("en");
1879        let blocks = blocks(&now);
1880        let overrides = Overrides::default();
1881        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
1882        let mut input = porch_input(&now);
1883        let mut club = sent_at("Ana <ana@example.org>", "Rehearsal moved", stamp("2026-10-05T09:12[Europe/Paris]"), false);
1884        club.card.account = Some("club".into());
1885        // Not shown on the card, even when the Porch would unfold them: set aside, waiting for your word on spam, hostile.
1886        let mut forged = sent_at("Bank <bank@bank.example>", "Your account", stamp("2026-10-05T09:50[Europe/Paris]"), false);
1887        forged.lane = Lane::SetAside;
1888        forged.priority = Priority::Above;
1889        let mut doubtful = sent_at("Offers <deals@shop.example>", "Big sale", stamp("2026-10-05T09:55[Europe/Paris]"), false);
1890        doubtful.lane = Lane::Review;
1891        input.items = vec![
1892            sent_at("Marie Dupont <marie@example.org>", "Dinner on Saturday", stamp("2026-10-04T18:30[Europe/Paris]"), true),
1893            club,
1894            sent_at("Paul <paul@example.org>", "The photos", stamp("2026-09-28T08:00[Europe/Paris]"), true),
1895            sent_at("Léa <lea@example.org>", "", stamp("2025-12-20T08:00[Europe/Paris]"), false),
1896            forged,
1897            doubtful,
1898        ];
1899        input.senders = Senders { safe: sioul_core::porch::SenderList::parse("marie@example.org\npaul@example.org\nlea@example.org\nana@example.org\n"), ..Senders::default() };
1900        let card = snapshot(&moment, Some(&input), None, None, &[]);
1901        let frame = &card.frames[0];
1902        let listed: Vec<&Mail> = frame.porch.as_ref().unwrap().mail.iter().map(|at| &card.mail[*at]).collect();
1903        // The newest first; the set-aside and the review queue's left out.
1904        assert_eq!(listed.iter().map(|m| m.sender.as_str()).collect::<Vec<_>>(), ["Ana", "Marie Dupont", "Paul", "Léa"]);
1905        // Its time today, until midnight; then "Yesterday"; then its day.
1906        let midnight = at("2026-10-06T00:00[Europe/Paris]").timestamp().as_millisecond();
1907        let next = at("2026-10-07T00:00[Europe/Paris]").timestamp().as_millisecond();
1908        let when = |m: &Mail| m.when.iter().map(|l| (l.until, l.text.clone())).collect::<Vec<_>>();
1909        assert_eq!(when(listed[0]), [(midnight, "09:12".to_string()), (next, "Yesterday".to_string()), (0, "5 Oct".to_string())]);
1910        assert_eq!(when(listed[1]), [(midnight, "Yesterday".to_string()), (0, "4 Oct".to_string())]);
1911        assert_eq!(when(listed[2]), [(0, "28 Sep".to_string())]);
1912        // Another year's: with its year.
1913        assert_eq!(when(listed[3]), [(0, "20 Dec 2025".to_string())]);
1914        // Not read yet (new/, or no S): bold in Java; read: not.
1915        assert_eq!(listed.iter().map(|m| m.unread).collect::<Vec<_>>(), [true, false, false, true]);
1916        // The account's place among yours: its colour in Java.
1917        assert_eq!((listed[0].account, listed[1].account), (1, 0));
1918        // No subject: said so.
1919        assert_eq!(listed[3].subject, "(no subject)");
1920        // Its file, to open it from the card.
1921        assert!(listed[0].key.ends_with(".sioul") && listed[0].key.contains("/new/"));
1922        // In French: "hier", "4 oct.", "1er".
1923        let fr = Translator::new("fr");
1924        let first = sent_at("Ana <ana@example.org>", "Hello", stamp("2026-10-01T09:00[Europe/Paris]"), false);
1925        assert_eq!(mail_when(&fr, first.card.date, &now).last().unwrap().text, "1er oct.");
1926        assert_eq!(mail_when(&fr, Some(stamp("2026-10-04T09:00[Europe/Paris]")), &now)[0].text, "hier");
1927        // At most twenty, the newest.
1928        let mut many = porch_input(&now);
1929        many.items = (0..30).map(|i| sent_at(&format!("Someone <s{i}@example.org>"), &format!("Note {i}"), stamp("2026-10-05T08:00[Europe/Paris]") - i * 60, false)).collect();
1930        many.senders = Senders { safe: sioul_core::porch::SenderList::parse("*@example.org\n"), ..Senders::default() };
1931        let card = snapshot(&moment, Some(&many), None, None, &[]);
1932        let shown = &card.frames[0].porch.as_ref().unwrap().mail;
1933        assert_eq!(shown.len(), 20);
1934        assert_eq!(card.mail[shown[0]].subject, "Note 0");
1935        // Each message written once, whichever frames list it.
1936        assert_eq!(card.mail.len(), 20);
1937    }
1938
1939    #[test]
1940    fn details_off_names_no_one() {
1941        let doses = [dose("Magnesium · 300 mg", "09:45", "2026-10-05T09:45[Europe/Paris]", i64::MAX / 2000)];
1942        let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), false, "en", &doses);
1943        assert!(!made.card.details);
1944        let frame = made.at("2026-10-05T10:00[Europe/Paris]");
1945        assert_eq!(frame.status, "Work until 12:10.");
1946        // That mail waits, no one named, no count.
1947        let porch = frame.porch.as_ref().unwrap();
1948        assert_eq!(porch.line, "Mail waits on the Porch.");
1949        assert!(porch.mail.is_empty() && made.card.mail.is_empty());
1950        // The step without its title, nor a reason naming another step: its date.
1951        let step = frame.step.as_ref().unwrap();
1952        assert_eq!((step.title.as_str(), step.why.as_str()), ("Your next step", "By 30 October: three weeks left"));
1953        // A code, without its site or the code itself.
1954        assert_eq!(made.card.codes.len(), 1);
1955        assert_eq!(made.card.codes[0].line, "A code waits on the Porch.");
1956        // No dose line: a medicine's name says health.
1957        assert!(made.card.doses.is_empty());
1958        // The appointments at their times, none named.
1959        let agenda = made.card.agenda.as_ref().unwrap();
1960        assert!(agenda.events.iter().all(|e| e.title == "An event"));
1961        assert_eq!(agenda.events.iter().find(|e| e.start == at("2026-10-06T11:00[Europe/Paris]").timestamp().as_millisecond()).unwrap().time, "11:00 – 12:00");
1962        let all = serde_json::to_string(&made.card).unwrap();
1963        for private in ["Marie", "Dinner", "482913", "La Banque", "CAF", "Magnesium", "Dentist", "Choir", "party", "Trip"] {
1964            assert!(!all.contains(private), "{private} shown with the details off: {all}");
1965        }
1966        let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), false, "fr", &[]);
1967        assert_eq!(fr.card.codes[0].line, "Un code attend sur le Porche.");
1968        assert_eq!(fr.at("2026-10-05T10:00[Europe/Paris]").porch.as_ref().unwrap().line, "Du courrier attend sur le Porche.");
1969        assert!(fr.card.agenda.as_ref().unwrap().events.iter().all(|e| e.title == "Un événement"));
1970    }
1971
1972    #[test]
1973    fn the_agenda_comes_day_by_day() {
1974        let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "en", &[]);
1975        let ms = |text: &str| at(text).timestamp().as_millisecond();
1976        let agenda = made.card.agenda.as_ref().unwrap();
1977        // Nearest first; the cancelled call left out.
1978        let titles: Vec<&str> = agenda.events.iter().map(|e| e.title.as_str()).collect();
1979        assert_eq!(titles, ["Choir", "Dentist", "Neighbours' party", "Trip to the sea", "Night train"]);
1980        let event = |title: &str| agenda.events.iter().find(|e| e.title == title).unwrap();
1981        // A time range; another day's end with its day; a whole day, its title alone; several, until when.
1982        assert_eq!(event("Dentist").time, "11:00 – 12:00");
1983        assert_eq!(event("Night train").time, "22:30 – Wed 21 Oct 06:45");
1984        assert_eq!((event("Neighbours' party").time.as_str(), event("Neighbours' party").all_day), ("", true));
1985        assert_eq!(event("Trip to the sea").time, "Until Fri 16 Oct");
1986        // Its calendar's colour, as "#rrggbb" whatever was written; none: Sioul's own.
1987        assert_eq!((event("Dentist").color.as_str(), event("Choir").color.as_str(), event("Neighbours' party").color.as_str()), ("#4c6b5c", "", "#b07d3f"));
1988        assert_eq!((event("Dentist").start, event("Dentist").end), (ms("2026-10-06T11:00[Europe/Paris]"), ms("2026-10-06T12:00[Europe/Paris]")));
1989        assert_eq!(event("Dentist").key, "/calendars/me/personal/event-0.ics");
1990        // Every day from today to a month on, each named as seen from the day before and on the day.
1991        assert_eq!(agenda.days.len(), 31);
1992        let names = |day: &Day| day.label.iter().map(|l| (l.until, l.text.clone())).collect::<Vec<_>>();
1993        assert_eq!(agenda.days[0].from, ms("2026-10-05T00:00[Europe/Paris]"));
1994        assert_eq!(names(&agenda.days[0]), [(ms("2026-10-06T00:00[Europe/Paris]"), "Today, Mon 5 Oct".to_string())]);
1995        assert_eq!(names(&agenda.days[1]), [(ms("2026-10-06T00:00[Europe/Paris]"), "Tomorrow, Tue 6 Oct".to_string()), (ms("2026-10-07T00:00[Europe/Paris]"), "Today, Tue 6 Oct".to_string())]);
1996        assert_eq!(
1997            names(&agenda.days[7]),
1998            [(ms("2026-10-11T00:00[Europe/Paris]"), "Mon 12 Oct".to_string()), (ms("2026-10-12T00:00[Europe/Paris]"), "Tomorrow, Mon 12 Oct".to_string()), (ms("2026-10-13T00:00[Europe/Paris]"), "Today, Mon 12 Oct".to_string())]
1999        );
2000        // The change of hour on 25 October: that day lasts 25 hours.
2001        let long = agenda.days.iter().find(|d| d.from == ms("2026-10-25T00:00[Europe/Paris]")).unwrap();
2002        assert_eq!(long.until - long.from, 25 * 3_600_000);
2003        assert_eq!(agenda.empty, "Nothing in your calendars for the coming month.");
2004        // Shown at every time but in a pause.
2005        assert!(made.card.frames.iter().all(|f| f.events));
2006        // An event over is left out; one running stays.
2007        let later = make("2026-10-05T19:30[Europe/Paris]", &Overrides::default(), true, "en", &[]);
2008        assert_eq!(later.card.agenda.as_ref().unwrap().events[0].title, "Choir");
2009        let after = make("2026-10-05T21:00[Europe/Paris]", &Overrides::default(), true, "en", &[]);
2010        assert_eq!(after.card.agenda.as_ref().unwrap().events[0].title, "Dentist");
2011        // Beyond a month: not listed.
2012        let config = config();
2013        let tr = Translator::new("en");
2014        let now = at("2026-10-05T10:00[Europe/Paris]");
2015        let blocks = blocks(&now);
2016        let overrides = Overrides::default();
2017        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2018        let far = events_of(&[("Far away", "2026-11-20T10:00[Europe/Paris]", "2026-11-20T11:00[Europe/Paris]", false, None, false)]);
2019        assert!(snapshot(&moment, None, None, Some(&far), &[]).agenda.unwrap().events.is_empty());
2020        // No calendar at all: no agenda on the card.
2021        let none = AgendaInput { events: Vec::new(), calendars: false, read_on: now.date(), aside: BTreeSet::new() };
2022        assert!(snapshot(&moment, None, None, Some(&none), &[]).agenda.is_none());
2023        // In French, as the phone's agenda says it.
2024        let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "fr", &[]);
2025        let agenda = fr.card.agenda.as_ref().unwrap();
2026        assert_eq!(names(&agenda.days[0])[0].1, "Aujourd’hui, lun. 5 oct.");
2027        assert_eq!(names(&agenda.days[3])[1].1, "Demain, jeu. 8 oct.");
2028        assert_eq!(names(&agenda.days[7])[0].1, "lun. 12 oct.");
2029        assert_eq!(agenda.events.iter().find(|e| e.title == "Trip to the sea").unwrap().time, "Jusqu’au ven. 16 oct.");
2030        assert_eq!(agenda.events.iter().find(|e| e.title == "Night train").unwrap().time, "22:30 – mer. 21 oct. 06:45");
2031        assert_eq!(fr.card.words.mail, "Courrier");
2032    }
2033
2034    #[test]
2035    fn no_count_at_all() {
2036        let now = at("2026-10-05T10:00[Europe/Paris]");
2037        let config = config();
2038        let overrides = Overrides::default();
2039        let blocks = blocks(&now);
2040        // A number anywhere but in a time (14:05) or a date: what a badge would be.
2041        let digits = regex_free_digits;
2042        for language in ["en", "fr"] {
2043            for n in [0, 1, 7, 12, 30] {
2044                for details in [true, false] {
2045                    let tr = Translator::new(language);
2046                    let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details, dnd: None };
2047                    let mut input = porch_input(&now);
2048                    input.items = (0..n).map(|i| message(&format!("Someone <s{i}@example.org>"), "Hello", Lane::People)).collect();
2049                    input.senders = Senders { safe: sioul_core::porch::SenderList::parse("*@example.org\n"), ..Senders::default() };
2050                    let card = snapshot(&moment, Some(&input), Some(&plan_input(&now)), Some(&some_events()), &[]);
2051                    for frame in &card.frames {
2052                        let shown = words(&card, frame);
2053                        assert!(!digits(&shown), "a number on the card: {shown}");
2054                        // Never how many came, in words either.
2055                        for count in ["letters came", "letter came", "lettres sont arrivées", "lettre est arrivée", "Many", "Beaucoup", "Seven", "Sept", "Twelve", "Douze"] {
2056                            assert!(!shown.contains(count), "a count on the card: {shown}");
2057                        }
2058                    }
2059                }
2060            }
2061        }
2062    }
2063
2064    /// Whether `text` holds a digit outside a time ("14:05") or a date ("30 October", "5 Oct", "1er oct.").
2065    fn regex_free_digits(text: &str) -> bool {
2066        let mut rest = String::new();
2067        let chars: Vec<char> = text.chars().collect();
2068        let mut i = 0;
2069        while i < chars.len() {
2070            // A time: two digits, ':', two digits.
2071            if chars[i].is_ascii_digit() && chars.get(i + 1).is_some_and(char::is_ascii_digit) && chars.get(i + 2) == Some(&':') && chars.get(i + 3).is_some_and(char::is_ascii_digit) && chars.get(i + 4).is_some_and(char::is_ascii_digit) {
2072                i += 5;
2073                continue;
2074            }
2075            rest.push(chars[i]);
2076            i += 1;
2077        }
2078        // A day of the month before its name, long or short: "30 October", "30 octobre", "5 Oct", "1er oct.".
2079        let months = [
2080            "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre", "Jan", "Feb", "Mar",
2081            "Apr", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "janv.", "févr.", "avr.", "juil.", "sept.", "oct.", "nov.", "déc.",
2082        ];
2083        let mut cleaned = rest.clone();
2084        for month in months {
2085            for day in (1..=31).rev() {
2086                cleaned = cleaned.replace(&format!("{day} {month}"), month).replace(&format!("{day}\u{a0}{month}"), month).replace(&format!("{day}er {month}"), month);
2087            }
2088        }
2089        cleaned.chars().any(|c| c.is_ascii_digit())
2090    }
2091
2092    fn dose(name: &str, time: &str, when: &str, known_until: i64) -> Dose {
2093        let due = at(when).timestamp().as_second();
2094        Dose { name: name.into(), time: time.into(), at: due, until: due + 12 * 3600, known_until }
2095    }
2096
2097    #[test]
2098    fn doses_say_what_is_known_and_no_more() {
2099        let due = at("2026-10-05T09:45[Europe/Paris]").timestamp().as_second();
2100        // Known not marked until 10:05 (another device heard at 10:00, five minutes' freshness).
2101        let doses = [dose("Magnesium · 300 mg", "09:45", "2026-10-05T09:45[Europe/Paris]", due + 20 * 60), dose("Levothyroxine · 75 µg", "06:00", "2026-10-06T06:00[Europe/Paris]", 0)];
2102        let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "en", &doses);
2103        let lines = &made.card.doses;
2104        assert_eq!(lines[0].line, "Magnesium · 300 mg, 09:45");
2105        assert_eq!(lines[0].check, "Magnesium · 300 mg, 09:45: check before taking it.");
2106        assert_eq!((lines[0].from, lines[0].known_until), (due * 1000, (due + 20 * 60) * 1000));
2107        // Not known from its time on: the check, at once (Java shows `check` when known_until is 0).
2108        assert_eq!(lines[1].known_until, 0);
2109        for line in lines {
2110            for word in [&line.line, &line.check] {
2111                let lower = word.to_lowercase();
2112                for never in ["missed", "late", "not taken", "overdue", "forgot"] {
2113                    assert!(!lower.contains(never), "{word}");
2114                }
2115            }
2116        }
2117        // Asleep: the dose shows (doses remind during sleep), not in a pause.
2118        assert!(made.at("2026-10-06T05:00[Europe/Paris]").doses);
2119        // Doses silent in sleep, as an older Sioul said it ([reminders] doses_in_sleep, read once to seed the matrix).
2120        let mut silent = config();
2121        silent.reminders.doses_in_sleep = false;
2122        let now = at("2026-10-05T10:00[Europe/Paris]");
2123        let tr = Translator::new("en");
2124        let blocks = blocks(&now);
2125        let overrides = Overrides::default();
2126        let moment = Moment { now: &now, config: &silent, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2127        let card = snapshot(&moment, None, None, None, &doses);
2128        let night = card.frames.iter().find(|f| f.kind == "sleep").unwrap();
2129        assert!(!night.doses, "doses during sleep: stay silent");
2130        assert!(night.codes, "codes you asked for come at once while you sleep (Q6; before, they waited on the Porch)");
2131        // As the matrix of what reaches you says (Settings ▸ What reaches you): doses held in sleep, codes on the Porch only then.
2132        let mut grid = config();
2133        grid.attention = toml::from_str::<Config>("[attention]\ndoses = [\"sleep:later\"]\ncodes = [\"sleep:never\"]\n").unwrap().attention;
2134        let card = snapshot(&Moment { config: &grid, ..moment }, None, None, None, &doses);
2135        let night = card.frames.iter().find(|f| f.kind == "sleep").unwrap();
2136        assert!(!night.doses && !night.codes);
2137        // In French, with its typography.
2138        let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "fr", &doses);
2139        assert_eq!(fr.card.doses[0].check, "Magnesium · 300 mg, 09:45\u{202f}: vérifiez avant de la prendre.");
2140    }
2141
2142    #[test]
2143    fn its_age_is_said_once_it_is_old() {
2144        let made = make("2026-10-05T14:05[Europe/Paris]", &Overrides::default(), true, "en", &[]);
2145        let card = &made.card;
2146        assert_eq!(card.stale_after, card.made + 15 * 60 * 1000);
2147        assert_eq!(card.stale[0].text, "at 14:05");
2148        assert_eq!(card.stale[0].until, at("2026-10-06T00:00[Europe/Paris]").timestamp().as_millisecond());
2149        assert_eq!(card.stale[1].text, "yesterday at 14:05");
2150        assert_eq!(card.stale[1].until, at("2026-10-07T00:00[Europe/Paris]").timestamp().as_millisecond());
2151        assert_eq!((card.stale[2].until, card.stale[2].text.as_str()), (0, "Mon 5 Oct 14:05"));
2152        assert_eq!(card.beyond, "Open Sioul to bring this card up to date.");
2153        let fr = make("2026-10-05T14:05[Europe/Paris]", &Overrides::default(), true, "fr", &[]);
2154        assert_eq!((fr.card.stale[0].text.as_str(), fr.card.stale[1].text.as_str()), ("à 14:05", "hier à 14:05"));
2155        // Written again a minute later, nothing new: said the same, Android need not draw it again.
2156        let config = config();
2157        let tr = Translator::new("en");
2158        let overrides = Overrides::default();
2159        let first = at("2026-10-05T14:05[Europe/Paris]");
2160        let (porch, plan) = (porch_input(&first), plan_input(&first));
2161        let card_at = |now: &Zoned| {
2162            let blocks = blocks(now);
2163            let moment = Moment { now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2164            snapshot(&moment, Some(&porch), Some(&plan), Some(&some_events()), &[])
2165        };
2166        let (before, after) = (card_at(&first), card_at(&at("2026-10-05T14:06[Europe/Paris]")));
2167        assert_ne!(before.made, after.made);
2168        assert_eq!(content(&before), content(&after));
2169        // A new letter: said differently.
2170        let mut more = porch_input(&first);
2171        more.items.push(message("Ana <ana@example.org>", "Tea?", Lane::People));
2172        let blocks = blocks(&first);
2173        let moment = Moment { now: &first, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2174        assert_ne!(content(&before), content(&snapshot(&moment, Some(&more), Some(&plan), Some(&some_events()), &[])));
2175    }
2176
2177    #[test]
2178    fn not_set_up_yet() {
2179        let now = at("2026-10-05T10:00[Europe/Paris]");
2180        let config = Config { accounts: Vec::new(), ..config() };
2181        let tr = Translator::new("en");
2182        let blocks = blocks(&now);
2183        let overrides = Overrides::default();
2184        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2185        let card = snapshot(&moment, Some(&porch_input(&now)), None, None, &[]);
2186        assert_eq!(card.frames[0].porch.as_ref().unwrap().line, "Sioul is not set up yet.");
2187        // Asleep it says only the night's line, set up or not.
2188        assert!(card.frames.iter().filter(|f| f.kind == "sleep").all(|f| f.porch.is_none()));
2189    }
2190
2191    /// The names HomeCard.java and HomeCardRows.java read, each where they
2192    /// read it: a name changed here and not there would leave the card blank.
2193    /// With SIOUL_CARD_SAMPLE set, the card is written there too (in French
2194    /// with SIOUL_CARD_SAMPLE_FR), to check the Java's rows on a computer:
2195    /// Thursday 8 October 2026 at 09:20, with the weather, reminders, a call,
2196    /// two events at once and a line on where you stopped.
2197    #[test]
2198    fn the_file_java_reads() {
2199        let doses = [dose("Magnesium · 300 mg", "09:45", "2026-10-08T09:45[Europe/Paris]", 0)];
2200        let made = full("2026-10-08T09:20[Europe/Paris]", true, "en", &doses);
2201        let json = serde_json::to_value(&made).unwrap();
2202        for (variable, language) in [("SIOUL_CARD_SAMPLE", "en"), ("SIOUL_CARD_SAMPLE_FR", "fr")] {
2203            if let Some(path) = std::env::var_os(variable) {
2204                let card = full("2026-10-08T09:20[Europe/Paris]", true, language, &doses);
2205                std::fs::write(path, serde_json::to_string_pretty(&card).unwrap()).unwrap();
2206            }
2207        }
2208        for key in ["made", "stale_after"] {
2209            assert!(json[key].is_i64(), "{key}");
2210        }
2211        assert!(json["details"].is_boolean() && json["beyond"].is_string());
2212        assert!(json["stale"][0]["until"].is_i64() && json["stale"][0]["text"].is_string());
2213        // Version 3: the frames under "times", none under "frames" (an older Java says `beyond`).
2214        assert!(json["frames"].is_null());
2215        let frame = &json["times"][0];
2216        for key in ["from", "until"] {
2217            assert!(frame[key].is_i64(), "{key}");
2218        }
2219        assert!(frame["status"].is_string() && frame["codes"].is_boolean() && frame["doses"].is_boolean() && frame["events"].is_boolean());
2220        assert!(frame["porch"]["line"].is_string() && frame["porch"]["mail"][0].is_u64());
2221        for key in ["title", "why", "uid"] {
2222            assert!(frame["step"][key].is_string(), "{key}");
2223        }
2224        assert!(frame["reminders"][0].is_u64() && frame["calls"][0].is_u64() && frame["overlaps"].is_boolean() && frame["stopped"].is_boolean() && frame["note"].is_string());
2225        assert!(json["v"] == 3 && json["words"]["mail"].is_string() && json["words"]["agenda"].is_string());
2226        // Today (HomeCard.java): the date's wordings, the weather hour by hour, the days, the credit.
2227        let today = &json["today"];
2228        assert!(today["date"][0]["until"].is_i64() && today["date"][0]["text"].is_string() && today["line"].is_string());
2229        let hour = &today["weather"]["hours"][0];
2230        assert!(hour["from"].is_i64() && hour["until"].is_i64());
2231        for slot in [&hour["now"], &hour["next"][0], &hour["parts"][0]] {
2232            for key in ["label", "icon", "temperature", "rain", "words"] {
2233                assert!(slot[key].is_string(), "{key}");
2234            }
2235        }
2236        let day = &today["weather"]["days"][0];
2237        assert!(day["from"].is_i64() && day["until"].is_i64());
2238        for key in ["label", "icon", "high", "low", "rain", "words"] {
2239            assert!(day[key].is_string(), "{key}");
2240        }
2241        assert!(today["weather"]["credit"].is_string());
2242        // What the Porch has (HomeCard.java).
2243        for key in ["line", "open"] {
2244            assert!(json["reminders"][0][key].is_string(), "{key}");
2245        }
2246        assert!(json["reminders"][0]["from"].is_i64() && json["reminders"][0]["until"].is_i64());
2247        assert!(json["calls"][0].is_string() && json["stopped"].is_string());
2248        assert!(json["overlaps"][0]["line"].is_string() && json["overlaps"][0]["from"].is_i64() && json["overlaps"][0]["until"].is_i64());
2249        // The messages (HomeCardRows.java).
2250        let mail = &json["mail"][0];
2251        for key in ["key", "sender", "subject", "text"] {
2252            assert!(mail[key].is_string(), "{key}");
2253        }
2254        assert!(mail["unread"].is_boolean() && mail["account"].is_i64());
2255        assert!(mail["when"][0]["until"].is_i64() && mail["when"][0]["text"].is_string());
2256        // The agenda (HomeCardRows.java).
2257        let agenda = &json["agenda"];
2258        assert!(agenda["empty"].is_string());
2259        assert!(agenda["days"][0]["from"].is_i64() && agenda["days"][0]["until"].is_i64() && agenda["days"][0]["label"][0]["until"].is_i64() && agenda["days"][0]["label"][0]["text"].is_string());
2260        let event = &agenda["events"][0];
2261        for key in ["key", "title", "time", "color"] {
2262            assert!(event[key].is_string(), "{key}");
2263        }
2264        assert!(event["all_day"].is_boolean() && event["start"].is_i64() && event["end"].is_i64());
2265        assert!(json["codes"][0]["line"].is_string() && json["codes"][0]["warning"].is_string() && json["codes"][0]["until"].is_i64());
2266        for key in ["from", "until", "known_until"] {
2267            assert!(json["doses"][0][key].is_i64(), "{key}");
2268        }
2269        assert!(json["doses"][0]["line"].is_string() && json["doses"][0]["check"].is_string());
2270        assert!(frame["dnd"].is_string());
2271        // Asleep: no Porch, no step, written as null (Java's optJSONObject).
2272        let night = serde_json::to_value(Made { card: made }.at("2026-10-08T23:30[Europe/Paris]")).unwrap();
2273        assert!(night["porch"].is_null() && night["step"].is_null());
2274    }
2275
2276    #[test]
2277    fn the_do_not_disturb_everywhere_says_its_line() {
2278        let now = at("2026-10-05T14:00[Europe/Paris]");
2279        let config = config();
2280        let tr = Translator::new("en");
2281        let blocks = blocks(&now);
2282        let overrides = Overrides::default();
2283        let until = at("2026-10-05T15:00[Europe/Paris]").timestamp().as_second();
2284        let line = "Do not disturb, on every device, until 15:00.";
2285        let moment = serde_json::json!({ "on": true, "why": "manual", "until": "15:00", "until_at": until, "line": line, "everywhere": true, "here": true });
2286        let dnd = Dnd::of(&moment).unwrap();
2287        let (porch, plan) = (porch_input(&now), plan_input(&now));
2288        let card_with = |dnd: Option<&Dnd>| {
2289            let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd };
2290            snapshot(&moment, Some(&porch), Some(&plan), None, &[])
2291        };
2292        let made = Made { card: card_with(Some(&dnd)) };
2293        // Under the status line until 15:00, where a frame ends; gone after.
2294        assert_eq!(made.at("2026-10-05T14:30[Europe/Paris]").dnd, line);
2295        assert_eq!(made.at("2026-10-05T14:30[Europe/Paris]").until, at("2026-10-05T15:00[Europe/Paris]").timestamp().as_millisecond());
2296        assert_eq!(made.at("2026-10-05T15:00[Europe/Paris]").dnd, "");
2297        // Off, or for a reason the status line says already (sleep, the pause, free time): nothing.
2298        for (on, why) in [(false, "manual"), (true, "sleep"), (true, "paused"), (true, "free-time")] {
2299            assert_eq!(Dnd::of(&serde_json::json!({ "on": on, "why": why, "line": line, "until_at": until })), None, "{on} {why}");
2300        }
2301        // A focus session's, its end no promise: the frame for now only.
2302        let focus = Dnd::of(&serde_json::json!({ "on": true, "why": "focus", "line": line, "until_at": 0 })).unwrap();
2303        assert_eq!(focus.until, None);
2304        // Until switched off: in every frame but the night's.
2305        let lasting = Dnd::of(&serde_json::json!({ "on": true, "why": "manual", "line": line, "until_at": 0 })).unwrap();
2306        let card = card_with(Some(&lasting));
2307        assert!(card.frames.iter().all(|f| (f.kind == "sleep") == f.dnd.is_empty()), "{:#?}", card.frames);
2308        // In a pause, its line alone.
2309        let paused = Overrides { paused_since: Some(now.timestamp().as_second() - 60), ..Overrides::default() };
2310        let moment = Moment { now: &now, config: &config, overrides: &paused, blocks: &blocks, tr: &tr, details: true, dnd: Some(&lasting) };
2311        assert!(snapshot(&moment, Some(&porch), Some(&plan), None, &[]).frames.iter().all(|f| f.dnd.is_empty()));
2312        // Its end not said: in the frame for now only.
2313        let unsaid = Dnd::of(&serde_json::json!({ "on": true, "why": "manual", "line": line })).unwrap();
2314        let card = card_with(Some(&unsaid));
2315        assert_eq!(card.frames[0].dnd, line);
2316        assert!(card.frames[1..].iter().all(|f| f.dnd.is_empty()));
2317        // Without the window (a receiver alone): the card on disk gets its lines, a frame cut at its end.
2318        let mut written = serde_json::to_value(card_with(None)).unwrap();
2319        let before = written["times"].as_array().unwrap().len();
2320        let stamp = now.timestamp().as_millisecond();
2321        assert!(patch_dnd(&mut written, Some(&dnd), stamp));
2322        let frames = written["times"].as_array().unwrap().clone();
2323        assert_eq!(frames.len(), before + 1, "cut at 15:00");
2324        let shown = |when: &str| {
2325            let t = at(when).timestamp().as_millisecond();
2326            frames.iter().find(|f| f["from"].as_i64().unwrap() <= t && t < f["until"].as_i64().unwrap()).unwrap()["dnd"].clone()
2327        };
2328        assert_eq!(shown("2026-10-05T14:59[Europe/Paris]"), line);
2329        assert_eq!(shown("2026-10-05T15:01[Europe/Paris]"), "");
2330        // Said again the same: nothing changes; switched off: the lines go.
2331        assert!(!patch_dnd(&mut written, Some(&dnd), stamp));
2332        assert!(patch_dnd(&mut written, None, stamp));
2333        assert!(written["times"].as_array().unwrap().iter().all(|f| f["dnd"] == ""));
2334        // A card an older Sioul wrote (version 2, its frames under "frames"): its lines said again all the same.
2335        let mut older = serde_json::json!({ "v": 2, "frames": [{ "from": 0, "until": i64::MAX, "kind": "work", "dnd": "" }] });
2336        assert!(patch_dnd(&mut older, Some(&lasting), stamp));
2337        assert_eq!(older["frames"][0]["dnd"], line);
2338    }
2339
2340    /// Open-Meteo's answer for Geneva, asked on Thursday 8 October 2026 at 09:29 (weather.rs's tests).
2341    const SAVED: &str = include_str!("../../sioul-core/tests/fixtures/weather/open-meteo.json");
2342
2343    /// The forecast kept, fetched a minute before `now`.
2344    fn forecast_at(now: &Zoned) -> Forecast {
2345        forecast::parse(SAVED, now.timestamp().as_second() - 60).unwrap()
2346    }
2347
2348    /// A reminder of `kind`, due at `at` until `until` (Paris times).
2349    fn reminder(kind: reminders::Kind, key: &str, at_: &str, until: &str, title: &str, body: &str, target: &str, work: bool) -> Reminder {
2350        Reminder { key: key.into(), kind, at: at(at_).timestamp().as_second(), until: at(until).timestamp().as_second(), title: title.into(), body: body.into(), target: target.into(), work, starts: None }
2351    }
2352
2353    /// A date asked for a work task, a payment, a paper from tomorrow, and an
2354    /// event's reminder before it (the agenda card's and Android's: not said here).
2355    fn some_reminders() -> Vec<Reminder> {
2356        use reminders::Kind;
2357        vec![
2358            reminder(Kind::Asked, "asked:form", "2026-10-08T09:00[Europe/Paris]", "2026-10-31T00:00[Europe/Paris]", "Asked for Friday 30 October", "Call the CAF about the housing aid", "sioul:task/form", true),
2359            reminder(Kind::Payment, "payment:rent", "2026-10-08T09:00[Europe/Paris]", "2026-10-10T00:00[Europe/Paris]", "Rent · €650", "Planned for Friday 9 October; the account will hold it.", "sioul:budget/home", false),
2360            reminder(Kind::Paper, "paper:passport", "2026-10-09T09:00[Europe/Paris]", "2026-11-02T00:00[Europe/Paris]", "Passport: valid until Sunday 1 November", "Renewing takes weeks (an appointment, then the making): a step now?", "sioul:paper/passport", false),
2361            reminder(Kind::Before, "before:dentist", "2026-10-08T10:45[Europe/Paris]", "2026-10-08T11:00[Europe/Paris]", "11:00 · Dentist, in 15 minutes", "", "/calendars/me/personal/event-0.ics", false),
2362        ]
2363    }
2364
2365    /// The calls declined as the Porch would list them: one, from the night
2366    /// before, its caller let through in work and leisure time, not asleep.
2367    fn some_calls(moment: &sioul_core::attention::Now, at_: &Zoned) -> Vec<String> {
2368        use sioul_core::attention::Column;
2369        if moment.is(Column::Sleep) || moment.is(Column::Pause) {
2370            return Vec::new();
2371        }
2372        let day = if at_.date() == Date::constant(2026, 10, 8) { "While you slept" } else { "Yesterday, while you slept" };
2373        vec![format!("{day}, Marie Dupont called at 07:40.")]
2374    }
2375
2376    /// The full card at `now` (a Thursday, 8 October 2026), as a phone shows it:
2377    /// the weather at a place chosen, the reminders, a call declined, two events
2378    /// at once at 11:00 and on Friday, a line left on where you stopped.
2379    fn full(now: &str, details: bool, language: &str, doses: &[Dose]) -> Snapshot {
2380        let now = at(now);
2381        let config = config();
2382        let tr = Translator::new(language);
2383        let blocks = blocks(&now);
2384        let overrides = Overrides::default();
2385        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details, dnd: None };
2386        let agenda = events_of(&[
2387            ("Dentist", "2026-10-08T11:00[Europe/Paris]", "2026-10-08T12:00[Europe/Paris]", false, None, false),
2388            ("School meeting", "2026-10-08T11:30[Europe/Paris]", "2026-10-08T12:30[Europe/Paris]", false, None, false),
2389            ("Choir", "2026-10-09T19:00[Europe/Paris]", "2026-10-09T20:30[Europe/Paris]", false, None, false),
2390            ("Neighbours' party", "2026-10-09T20:00[Europe/Paris]", "2026-10-09T23:00[Europe/Paris]", false, None, false),
2391            ("Trip to the sea", "2026-10-14T00:00[Europe/Paris]", "2026-10-17T00:00[Europe/Paris]", true, None, false),
2392        ]);
2393        let reminded = some_reminders();
2394        let more = More { weather: WeatherInput { place: true, forecast: Some(forecast_at(&now)) }, reminders: &reminded, calls: Some(&some_calls), stopped: Some("Halfway through the CAF's form, page 2".into()) };
2395        snapshot_with(&moment, Some(&porch_input(&now)), Some(&plan_input(&now)), Some(&agenda), doses, &more)
2396    }
2397
2398    #[test]
2399    fn today_comes_first_hour_by_hour() {
2400        let card = full("2026-10-08T09:20[Europe/Paris]", true, "en", &[]);
2401        let ms = |text: &str| at(text).timestamp().as_millisecond();
2402        // The date, then tomorrow's from midnight: the frames end with tomorrow.
2403        let dates: Vec<(i64, &str)> = card.today.date.iter().map(|l| (l.until, l.text.as_str())).collect();
2404        assert_eq!(dates, [(ms("2026-10-09T00:00[Europe/Paris]"), "Thursday 8 October"), (ms("2026-10-10T00:00[Europe/Paris]"), "Friday 9 October")]);
2405        assert_eq!(card.today.line, "");
2406        let weather = card.today.weather.as_ref().unwrap();
2407        assert_eq!(weather.credit, "Weather: Open-Meteo.com");
2408        // Hour by hour from the hour under way to the end of the last frame (Saturday's midnight).
2409        assert_eq!(weather.hours.first().unwrap().from, ms("2026-10-08T09:00[Europe/Paris]"));
2410        assert_eq!(weather.hours.last().unwrap().until, card.frames.last().unwrap().until);
2411        assert!(weather.hours.windows(2).all(|w| w[0].until == w[1].from), "no hole between the hours");
2412        // Each hour says its own now, its next four hours, its next parts of the day; every
2413        // hour of every frame has its four (the forecast holds three days).
2414        assert!(weather.hours.iter().all(|h| h.next.len() == 4 && h.parts.len() == 4), "{:#?}", weather.hours.iter().map(|h| (h.from, h.next.len(), h.parts.len())).collect::<Vec<_>>());
2415        let hour = |text: &str| weather.hours.iter().find(|h| h.from <= ms(text) && ms(text) < h.until).unwrap();
2416        let morning = hour("2026-10-08T09:20[Europe/Paris]");
2417        assert_eq!((morning.now.temperature.as_str(), morning.now.rain.as_str()), ("16°", "98 %"));
2418        assert_eq!(morning.next.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["10:00", "11:00", "12:00", "13:00"]);
2419        assert_eq!(morning.parts.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["Afternoon", "Evening", "Night", "Morning"]);
2420        let afternoon = hour("2026-10-08T14:30[Europe/Paris]");
2421        assert_eq!((afternoon.now.temperature.as_str(), afternoon.next[0].label.as_str()), ("14°", "15:00"));
2422        // The afternoon's last hours are among the four: the evening comes first.
2423        assert_eq!(afternoon.parts.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["Evening", "Night", "Morning", "Afternoon"]);
2424        let late = hour("2026-10-09T22:10[Europe/Paris]");
2425        assert_eq!(late.next.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["23:00", "00:00", "01:00", "02:00"]);
2426        // The days, each from its midnight to the next: Java shows the seven after today.
2427        assert_eq!(weather.days.len(), 9);
2428        assert_eq!((weather.days[1].from, weather.days[1].until), (ms("2026-10-09T00:00[Europe/Paris]"), ms("2026-10-10T00:00[Europe/Paris]")));
2429        assert_eq!((weather.days[1].day.label.as_str(), weather.days[1].day.high.as_str(), weather.days[1].day.low.as_str()), ("Fri", "17°", "13°"));
2430        // In French, the heading with its capital.
2431        let fr = full("2026-10-08T09:20[Europe/Paris]", true, "fr", &[]);
2432        assert_eq!(fr.today.date[0].text, "Jeudi 8 octobre");
2433        assert_eq!(fr.today.weather.as_ref().unwrap().credit, "Météo\u{202f}: Open-Meteo.com");
2434        // Details off: the weather is no detail, said all the same.
2435        let plain = full("2026-10-08T09:20[Europe/Paris]", false, "en", &[]);
2436        assert_eq!(plain.today, card.today);
2437    }
2438
2439    #[test]
2440    fn no_place_no_weather() {
2441        let now = at("2026-10-08T09:20[Europe/Paris]");
2442        let config = config();
2443        let tr = Translator::new("en");
2444        let blocks = blocks(&now);
2445        let overrides = Overrides::default();
2446        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2447        let with = |weather: WeatherInput| snapshot_with(&moment, None, None, None, &[], &More { weather, ..More::default() }).today;
2448        // No place chosen: no weather part, one quiet line saying where to choose it.
2449        let none = with(WeatherInput::default());
2450        assert_eq!((none.weather.is_none(), none.line.as_str()), (true, "Choose a place for the weather in Sioul: tap the weather in its status line."));
2451        assert_eq!(none.date[0].text, "Thursday 8 October");
2452        // A place, but no forecast yet, or one kept from two days ago: none yet.
2453        for forecast in [None, Some(forecast::parse(SAVED, now.timestamp().as_second() - 2 * 86_400).unwrap())] {
2454            let shown = with(WeatherInput { place: true, forecast });
2455            assert_eq!((shown.weather.is_none(), shown.line.as_str()), (true, "No forecast yet."));
2456        }
2457        // In French, with its typography.
2458        let fr = Translator::new("fr");
2459        let moment = Moment { tr: &fr, ..moment };
2460        let said = snapshot_with(&moment, None, None, None, &[], &More::default()).today.line;
2461        assert_eq!(said, "Choisissez un lieu pour la météo dans Sioul\u{202f}: touchez la météo dans sa ligne d’état.");
2462    }
2463
2464    #[test]
2465    fn the_porch_lines_but_no_mail() {
2466        let card = full("2026-10-08T09:20[Europe/Paris]", true, "en", &[dose("Magnesium · 300 mg", "09:45", "2026-10-08T09:45[Europe/Paris]", 0)]);
2467        let made = Made { card };
2468        let card = &made.card;
2469        // The reminders of dates, waits, payments and papers, soonest first; never an event's (Android tells those).
2470        let lines: Vec<&str> = card.reminders.iter().map(|r| r.line.as_str()).collect();
2471        assert_eq!(lines, ["Call the CAF about the housing aid · Asked for Friday 30 October", "Rent · €650 · Planned for Friday 9 October; the account will hold it.", "Passport: valid until Sunday 1 November"]);
2472        assert_eq!((card.reminders[0].open.as_str(), card.reminders[2].from), ("sioul:task/form", at("2026-10-09T09:00[Europe/Paris]").timestamp().as_millisecond()));
2473        // At work, all three (Java shows each from its time); in the evening, work's
2474        // date waits for work; asleep, none; their row says so.
2475        assert_eq!(made.at("2026-10-08T10:00[Europe/Paris]").reminders, [0, 1, 2]);
2476        assert_eq!(made.at("2026-10-08T17:30[Europe/Paris]").reminders, [1, 2]);
2477        assert!(made.at("2026-10-08T23:30[Europe/Paris]").reminders.is_empty());
2478        // The call declined, as the Porch lists it, said from each day.
2479        let work = made.at("2026-10-08T10:00[Europe/Paris]");
2480        assert_eq!(card.calls[work.calls[0]], "While you slept, Marie Dupont called at 07:40.");
2481        assert_eq!(card.calls[made.at("2026-10-09T10:00[Europe/Paris]").calls[0]], "Yesterday, while you slept, Marie Dupont called at 07:40.");
2482        assert!(made.at("2026-10-08T23:30[Europe/Paris]").calls.is_empty());
2483        // Two events at once: today's, then Friday's on Friday; never asleep.
2484        let overlaps: Vec<(&str, i64)> = card.overlaps.iter().map(|o| (o.line.as_str(), o.from)).collect();
2485        assert_eq!(
2486            overlaps,
2487            [
2488                ("Today, two events at once: Dentist and School meeting.", at("2026-10-08T00:00[Europe/Paris]").timestamp().as_millisecond()),
2489                ("Today, two events at once: Choir and Neighbours' party.", at("2026-10-09T00:00[Europe/Paris]").timestamp().as_millisecond()),
2490            ]
2491        );
2492        assert_eq!(card.overlaps[0].until, at("2026-10-08T12:30[Europe/Paris]").timestamp().as_millisecond());
2493        assert!(work.overlaps && !made.at("2026-10-08T23:30[Europe/Paris]").overlaps);
2494        // Now: the step and where you stopped, in work time; not in the evening.
2495        assert_eq!(card.stopped, "Where you stopped: Halfway through the CAF's form, page 2");
2496        assert!(work.stopped && work.step.is_some());
2497        assert!(!made.at("2026-10-08T17:30[Europe/Paris]").stopped);
2498        // Nothing of the mail on the full card: its lines name no sender and no subject
2499        // (the mail card lists them, from the same file).
2500        for frame in &card.frames {
2501            let shown = full_card_words(card, frame);
2502            for mail in ["Marie Dupont <", "Dinner on Saturday", "The photos", "Paul", "Léa", "A question"] {
2503                assert!(!shown.contains(mail), "{mail} on the full card: {shown}");
2504            }
2505        }
2506        assert!(!card.mail.is_empty(), "the mail card's messages are still written");
2507        // Details off: none named.
2508        let plain = full("2026-10-08T09:20[Europe/Paris]", false, "en", &[]);
2509        assert!(plain.reminders.iter().all(|r| r.line == "A reminder waits in Sioul."));
2510        assert_eq!(plain.calls, ["A call was declined: it is on the Porch."]);
2511        assert_eq!(plain.overlaps[0].line, "Today, two events at once.");
2512        assert_eq!(plain.stopped, "Your line on where you stopped waits in Sioul.");
2513        let all = serde_json::to_string(&serde_json::json!([plain.reminders, plain.calls, plain.overlaps, plain.stopped])).unwrap();
2514        for private in ["CAF", "Rent", "Passport", "Marie", "Dentist", "Choir", "Halfway"] {
2515            assert!(!all.contains(private), "{private} shown with the details off: {all}");
2516        }
2517        // In French.
2518        let fr = full("2026-10-08T09:20[Europe/Paris]", true, "fr", &[]);
2519        assert_eq!(fr.overlaps[0].line, "Aujourd’hui, deux événements en même temps\u{202f}: Dentist et School meeting.");
2520        assert_eq!(fr.stopped, "Où vous en étiez\u{202f}: Halfway through the CAF's form, page 2");
2521        // In a pause: none of it.
2522        let now = at("2026-10-08T10:00[Europe/Paris]");
2523        let config = config();
2524        let tr = Translator::new("en");
2525        let blocks = blocks(&now);
2526        let paused = Overrides { paused_since: Some(now.timestamp().as_second() - 60), ..Overrides::default() };
2527        let moment = Moment { now: &now, config: &config, overrides: &paused, blocks: &blocks, tr: &tr, details: true, dnd: None };
2528        let reminded = some_reminders();
2529        let card = snapshot_with(&moment, None, None, None, &[], &More { reminders: &reminded, calls: Some(&some_calls), stopped: Some("Halfway".into()), ..More::default() });
2530        assert!(card.frames.iter().all(|f| f.reminders.is_empty() && f.calls.is_empty() && !f.overlaps && !f.stopped));
2531    }
2532
2533    /// Every word the full card can show in `frame` (HomeCard.java): today, the
2534    /// status line, the Porch's lines, Now.
2535    fn full_card_words(card: &Snapshot, frame: &Frame) -> String {
2536        let mut out: Vec<String> = card.today.date.iter().map(|l| l.text.clone()).collect();
2537        out.extend([card.today.line.clone(), frame.status.clone(), frame.dnd.clone(), frame.note.clone(), card.stopped.clone()]);
2538        out.extend(card.codes.iter().flat_map(|c| [c.line.clone(), c.warning.clone()]));
2539        out.extend(card.doses.iter().flat_map(|d| [d.line.clone(), d.check.clone()]));
2540        out.extend(frame.reminders.iter().map(|at| card.reminders[*at].line.clone()));
2541        out.extend(frame.calls.iter().map(|at| card.calls[*at].clone()));
2542        out.extend(card.overlaps.iter().map(|o| o.line.clone()));
2543        out.extend(frame.step.iter().map(|s| s.title.clone()));
2544        out.join("\n")
2545    }
2546
2547    #[test]
2548    fn a_reminders_tap_opens_what_it_is_about() {
2549        let now = 1_791_300_000_000;
2550        let none = String::new;
2551        let tap_on = |key: &str| tap(&format!(r#"{{"open":"reminder","key":"{key}","at":{now}}}"#), now);
2552        assert_eq!(tap_on("sioul:task/form"), Some(("task", "sioul:task/form".to_string(), "form".to_string())));
2553        assert_eq!(tap_on("sioul:budget/home"), Some(("budget", "sioul:budget/home".to_string(), none())));
2554        assert_eq!(tap_on("sioul:paper/passport"), Some(("paper", "sioul:paper/passport".to_string(), "passport".to_string())));
2555        assert_eq!(tap_on("sioul:contract/box"), Some(("contract", "sioul:contract/box".to_string(), "box".to_string())));
2556        assert_eq!(tap_on(""), Some(("porch", none(), none())));
2557    }
2558
2559    #[test]
2560    fn a_tap_is_taken_once_and_not_late() {
2561        let now = 1_791_300_000_000;
2562        let none = String::new;
2563        assert_eq!(tap(&format!(r#"{{"open":"porch","at":{now}}}"#), now + 1000), Some(("porch", none(), none())));
2564        assert_eq!(tap(&format!(r#"{{"open":"now","uid":"a b/c","at":{now}}}"#), now), Some(("now", "sioul:task/a%20b%2Fc".to_string(), none())));
2565        assert_eq!(tap(&format!(r#"{{"open":"now","uid":"","at":{now}}}"#), now), Some(("now", none(), none())));
2566        // A message, on the Porch in its Reader; an event, in the Agenda; by their files.
2567        let file = "/mail/me/INBOX/new/1759600000.a.sioul";
2568        assert_eq!(tap(&format!(r#"{{"open":"mail","key":"{file}","at":{now}}}"#), now), Some(("card-mail", none(), file.to_string())));
2569        assert_eq!(tap(&format!(r#"{{"open":"event","key":"/calendars/me/personal/a.ics","at":{now}}}"#), now), Some(("event", none(), "/calendars/me/personal/a.ics".to_string())));
2570        assert_eq!(tap(&format!(r#"{{"open":"agenda","at":{now}}}"#), now), Some(("agenda", none(), none())));
2571        // Without their files, their pages.
2572        assert_eq!(tap(&format!(r#"{{"open":"mail","at":{now}}}"#), now), Some(("porch", none(), none())));
2573        assert_eq!(tap(&format!(r#"{{"open":"event","key":"","at":{now}}}"#), now), Some(("agenda", none(), none())));
2574        // Older than five minutes: Sioul did not come up for it.
2575        assert_eq!(tap(&format!(r#"{{"open":"porch","at":{now}}}"#), now + 6 * 60 * 1000), None);
2576        assert_eq!(tap(r#"{"open":"elsewhere","at":0}"#, 0), None);
2577        assert_eq!(tap("not json", now), None);
2578    }
2579
2580    /// New mail between two drawings of the card: its messages and codes made
2581    /// again as a whole card would make them, frame by frame; the rest of the
2582    /// card, and the frames that say nothing of the Porch, left as they were.
2583    #[test]
2584    fn patch_mail_makes_the_porch_again_and_nothing_else() {
2585        let now = at("2026-10-05T10:00[Europe/Paris]");
2586        let config = config();
2587        let tr = Translator::new("en");
2588        let blocks = blocks(&now);
2589        let overrides = Overrides::default();
2590        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2591        let (plan, agenda) = (plan_input(&now), some_events());
2592        let before = porch_input(&now);
2593        let written = serde_json::to_value(snapshot(&moment, Some(&before), Some(&plan), Some(&agenda), &[])).unwrap();
2594        // Since then: Tom's letter read and gone from the Porch, one from Paul come, the code used up.
2595        let mut after = porch_input(&now);
2596        after.items.retain(|m| m.card.from_address.as_deref() != Some("tom@example.org") && m.code.is_none());
2597        after.items.insert(0, sent_at("Paul <paul@example.org>", "Tickets for Sunday", now.timestamp().as_second() - 60, false));
2598        let mut card = written.clone();
2599        assert!(patch_mail(&mut card, &moment, &after));
2600        let whole = serde_json::to_value(snapshot(&moment, Some(&after), Some(&plan), Some(&agenda), &[])).unwrap();
2601        assert_eq!(card["mail"], whole["mail"]);
2602        assert_eq!(card["codes"], whole["codes"]);
2603        assert_eq!(card["codes"], serde_json::json!([]), "the code gone with its message");
2604        let (frames, made, was) = (card["times"].as_array().unwrap(), whole["times"].as_array().unwrap(), written["times"].as_array().unwrap());
2605        assert_eq!(frames.len(), made.len());
2606        for ((frame, made), was) in frames.iter().zip(made).zip(was) {
2607            assert_eq!(frame["porch"], made["porch"], "the frame from {}", frame["from"]);
2608            // Its other words as they were: its line, its step, its agenda.
2609            for key in ["from", "until", "status", "step", "note", "events", "codes", "doses"] {
2610                assert_eq!(frame[key], was[key], "{key}");
2611            }
2612            if was["porch"].is_null() {
2613                assert!(frame["porch"].is_null(), "asleep, no Porch made");
2614            }
2615        }
2616        let senders: Vec<&str> = card["mail"].as_array().unwrap().iter().map(|m| m["sender"].as_str().unwrap()).collect();
2617        assert!(senders.contains(&"Paul") && !senders.contains(&"Tom"), "{senders:?}");
2618        for key in ["today", "agenda", "words", "made", "stale_after", "details"] {
2619            assert_eq!(card[key], written[key], "{key}");
2620        }
2621        // Nothing new since: nothing changed, nothing to write.
2622        assert!(!patch_mail(&mut card, &moment, &after));
2623        // A card written by another version: left alone.
2624        let mut older = written.clone();
2625        older["v"] = serde_json::json!(2);
2626        let kept = older.clone();
2627        assert!(!patch_mail(&mut older, &moment, &after));
2628        assert_eq!(older, kept);
2629    }
2630}