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, m.config.agenda.between())
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            // At once only when their times overlap; else closer than the time you keep (22 October 2026).
619            let line = match (m.details, o.at_once()) {
620                (true, true) => said(m.tr, "home-card-overlap", &[("first", title(&o.first)), ("second", title(&o.second))]),
621                (true, false) => said(m.tr, "home-card-overlap-close", &[("first", title(&o.first)), ("second", title(&o.second)), ("gap", o.gap.to_string()), ("needed", o.needed.to_string())]),
622                (false, true) => m.tr.text("home-card-overlap-plain", None),
623                (false, false) => m.tr.text("home-card-overlap-close-plain", None),
624            };
625            Some(Timed { line, from: ms(&from), until: o.first.end.max(o.second.end) * 1000 })
626        })
627        .collect()
628}
629
630/// The changes of time from now to the end of tomorrow (Health's meals and
631/// nights are known that far: `Needs::kept_around`), each with what it is
632/// for: a frame ends when the time changes (`quiet::mode`'s `until`), when a
633/// meal, a nap or the night begins, starts proper (winding down becomes
634/// sleep at bedtime) or ends, when the Porch opens or closes, when it stops
635/// resting after a pause, and at midnight, so that "tomorrow" in a frame's
636/// words stays true.
637fn timeline(m: &Moment) -> Vec<(Zoned, Zoned, Mode)> {
638    let Some(horizon) = midnight_after(m.now).and_then(|z| midnight_after(&z)) else { return Vec::new() };
639    let hours = m.config.week_hours();
640    let zone = m.now.time_zone().clone();
641    let instant = |seconds: i64| Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()));
642    let rests = m.overrides.porch_rests_until.and_then(instant);
643    // The do-not-disturb's end too: its line goes then.
644    let quiet_end = m.dnd.and_then(|d| d.until).filter(|until| *until > 0).and_then(instant);
645    let kept: Vec<Zoned> = m.blocks.kept.iter().flat_map(|k| [k.start, k.at, k.end]).filter_map(instant).chain(quiet_end).collect();
646    let mut frames = Vec::new();
647    let mut at = m.now.clone();
648    while at.timestamp() < horizon.timestamp() && frames.len() < FRAMES {
649        let mode = quiet::mode(&hours, &m.config.time_off, m.overrides, m.blocks, &at);
650        let mut ends = vec![horizon.clone()];
651        ends.extend(midnight_after(&at));
652        ends.extend(mode.until.clone());
653        ends.extend(kept.iter().cloned());
654        ends.extend(window::current(&m.config.windows, &at).map(|(_, closing)| closing));
655        ends.extend(window::next_opening(&m.config.windows, &at));
656        ends.extend(rests.clone());
657        let end = ends
658            .into_iter()
659            .filter(|e| e.timestamp() > at.timestamp())
660            .min_by_key(Zoned::timestamp)
661            .unwrap_or_else(|| at.checked_add(Span::new().minutes(1)).unwrap_or_else(|_| horizon.clone()));
662        frames.push((at.clone(), end.clone(), mode));
663        at = end;
664    }
665    frames
666}
667
668/// What a frame is, for Java's colours and tests: the time's word, or the pause's.
669fn kind(mode: &Mode) -> &'static str {
670    match mode.reason {
671        Reason::Paused => "paused",
672        Reason::FreeTime => "free-time",
673        _ => mode.time.id(),
674    }
675}
676
677/// The status line's sentence for `mode`, as seen from `at` (`backend::mode_json`'s,
678/// and "Work until 17:00." in working hours, where the window's line says nothing).
679fn status(m: &Moment, mode: &Mode, at: &Zoned) -> String {
680    let until = mode.until.as_ref().map(|z| quiet::until_text(m.tr, z, at)).unwrap_or_default();
681    let id = match mode.reason {
682        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),
683        Reason::Paused => return m.tr.text("mode-paused", None),
684        // Every other sentence says until when: none known, none said.
685        _ if until.is_empty() => return String::new(),
686        Reason::NoHours => return String::new(),
687        Reason::Working | Reason::Extended => "home-card-work",
688        Reason::TimeOff => "mode-time-off",
689        Reason::WorkingLate => "mode-working-late",
690        Reason::WorkNow => "mode-work-now-line",
691        Reason::AdminTime => "mode-admin",
692        Reason::DoneForTheDay => "mode-quiet",
693        Reason::Evening | Reason::DayOff => "mode-leisure",
694        Reason::Meal => "mode-meal",
695        Reason::WindingDown => "mode-wind-down",
696        Reason::Nap => "mode-nap",
697        Reason::Sleep => "mode-sleep",
698    };
699    let mut args = i18n::args();
700    args.set("until", until);
701    args.set("label", if mode.label.trim().is_empty() { "none".to_string() } else { mode.label.clone() });
702    m.tr.text(id, Some(&args))
703}
704
705/// "Thu 8 Oct", "jeu. 8 oct.": a day as an agenda card names it.
706fn day_short(tr: &Translator, day: Date) -> String {
707    let mut args = i18n::args();
708    args.set("weekday", tr.weekday_short(day));
709    args.set("day", day.day());
710    args.set("month", tr.month_short(day));
711    tr.text("date-day", Some(&args))
712}
713
714/// "7 Oct", "7 oct.", "1er oct.": a message's day, as a mail card dates it;
715/// with its year when it is not `today`'s.
716fn day_month_short(tr: &Translator, day: Date, today: Date) -> String {
717    let mut args = i18n::args();
718    args.set("day", day.day());
719    args.set("month", tr.month_short(day));
720    let said = tr.text("date-day-month", Some(&args));
721    if day.year() == today.year() { said } else { format!("{said} {}", day.year()) }
722}
723
724/// When a message came, as the card dates it, each wording until it holds no
725/// longer (Java picks the first whose `until` is ahead, 0 for good): its time
726/// today; "Yesterday" from midnight; its day after. Seen from `now`.
727fn mail_when(tr: &Translator, sent: Option<i64>, now: &Zoned) -> Vec<Label> {
728    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() };
729    let (day, today) = (at.date(), now.date());
730    let first = midnight_after(now);
731    let second = first.as_ref().and_then(midnight_after);
732    let yesterday = tr.text("home-card-yesterday", None);
733    let mut labels = Vec::new();
734    match (first, second) {
735        (Some(first), Some(second)) if day == today => {
736            labels.push(Label { until: ms(&first), text: at.strftime("%H:%M").to_string() });
737            labels.push(Label { until: ms(&second), text: yesterday });
738        }
739        (Some(first), _) if today.yesterday().is_ok_and(|y| y == day) => labels.push(Label { until: ms(&first), text: yesterday }),
740        _ => {}
741    }
742    labels.push(Label { until: 0, text: day_month_short(tr, day, today) });
743    labels
744}
745
746/// A message the Porch shows, as the card lists it: from the Porch's line
747/// (`item`, which keeps hostile and rude words out) and its message (`triaged`:
748/// when it came, whether it was read, the account it came to). With when it
749/// came, for the order.
750fn mail_line(m: &Moment, item: &view::ItemView, triaged: Option<&Triaged>) -> (i64, Mail) {
751    let sent = triaged.and_then(|t| t.card.date);
752    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);
753    // Read, by its file's flags (`S`), as the Mail page says it; a file not named so (new/): not read.
754    let unread = !sioul_core::maildir::flags_of(std::path::Path::new(&item.key)).contains('S');
755    let text: String = item.preview.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(TEXT_CHARS).collect();
756    let subject = if item.subject.trim().is_empty() { m.tr.text("mail-no-subject", None) } else { item.subject.clone() };
757    (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) })
758}
759
760/// What the Porch shows at `at`, as `backend::compute` makes it: the mail
761/// the matrix of what reaches you shows at that time (`Attention::mail`,
762/// Free time's "Nothing at all" too), the Porch open in its hours and in quiet time, closed
763/// otherwise with when it opens, resting after a pause until its next hours
764/// (`pauses::porch_rests`). Never "Open it anyway": a moment's choice in the
765/// window, not the card's. Its messages newest first, as a mail card lists
766/// them; never a count.
767fn porch_at(m: &Moment, input: &PorchInput, mode: &Mode, at: &Zoned) -> PorchMade {
768    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()) {
769        let until = until.to_zoned(at.time_zone().clone());
770        return PorchMade::said(said(m.tr, "home-card-porch-rests", &[("when", when(m.tr, &until, at))]));
771    }
772    let attention = sioul_core::attention::Attention::of(m.config);
773    let mut moment = sioul_core::attention::Now::of(mode);
774    moment.nothing = mode.free() && pause::nothing_now(m.overrides, &m.config.free_time);
775    let always = sioul_core::everywhere::People::load(&sioul_core::everywhere::People::default_path());
776    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);
777    let mail: Vec<Triaged> = input.items.iter().filter(|t| attention.mail(t, &input.senders, &always, area_of(t), &moment).shown).cloned().collect();
778    let shown = view::porch(&mail, m.config, input.store.as_ref(), m.tr, at, mode.quiet);
779    if !shown.open {
780        return PorchMade::said(match window::next_opening(&m.config.windows, at) {
781            Some(next) => said(m.tr, "home-card-porch-opens", &[("when", when(m.tr, &next, at))]),
782            None => m.tr.text("home-card-porch-closed", None),
783        });
784    }
785    // The lanes the Porch shows unfolded; never what it folds (filed, less
786    // important), whose words stay behind a click, and never what is set
787    // aside, waits for your word on spam, or is hostile, even unfolded.
788    let triaged: BTreeMap<String, &Triaged> = mail.iter().filter_map(|t| t.card.path.as_ref().map(|p| (p.display().to_string(), t))).collect();
789    let mut listed: Vec<(i64, Mail)> = shown
790        .lanes
791        .iter()
792        .filter(|l| !l.folded && !matches!(l.key.as_str(), "set-aside" | "review" | "hostile"))
793        .flat_map(|l| l.items.iter())
794        .filter(|i| !i.hidden && !i.spam)
795        .map(|i| mail_line(m, i, triaged.get(&i.key).copied()))
796        .collect();
797    if listed.is_empty() {
798        return PorchMade::said(m.tr.text("home-card-porch-empty", None));
799    }
800    // Details off: that mail waits, no one named, no count.
801    if !m.details {
802        return PorchMade::said(m.tr.text("home-card-mail-waits", None));
803    }
804    // The newest first, as a mail card lists them.
805    listed.sort_by_key(|(sent, _)| std::cmp::Reverse(*sent));
806    PorchMade { line: String::new(), mail: listed.into_iter().take(MAIL).map(|(_, mail)| mail).collect() }
807}
808
809/// A calendar's colour as "#rrggbb", whatever its tool wrote ("#4C6B5CFF");
810/// "" when it gave none, or one that does not read.
811fn colour(color: Option<&str>) -> String {
812    let Some(color) = color.map(str::trim).filter(|c| c.starts_with('#')) else { return String::new() };
813    let hex: String = color.chars().skip(1).take(6).collect();
814    if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) { format!("#{}", hex.to_ascii_lowercase()) } else { String::new() }
815}
816
817/// An event's time, as an agenda card says it: "11:00 – 12:00"; ending
818/// another day, "23:00 – Fri 9 Oct 01:00"; a whole day, nothing; several
819/// whole days, "Until Fri 9 Oct".
820fn event_time(tr: &Translator, event: &Occurrence, zone: &jiff::tz::TimeZone) -> String {
821    let at = |seconds: i64| Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()));
822    let (Some(start), Some(end)) = (at(event.start), at(event.end.max(event.start))) else { return String::new() };
823    // The last moment it covers: an end at midnight belongs to the day before.
824    let last = at(event.end.max(event.start + 1) - 1).map_or_else(|| start.date(), |z| z.date());
825    if event.all_day {
826        return if last > start.date() { said(tr, "home-card-until-day", &[("day", day_short(tr, last))]) } else { String::new() };
827    }
828    let from = start.strftime("%H:%M").to_string();
829    if event.end <= event.start {
830        return from;
831    }
832    let to = if last == start.date() { end.strftime("%H:%M").to_string() } else { tr.date(&end, true) };
833    said(tr, "home-card-time-range", &[("start", from), ("end", to)])
834}
835
836/// A day of the agenda and its names: "Mon 12 Oct" until the day before,
837/// "Tomorrow, Mon 12 Oct" that day, "Today, Mon 12 Oct" on the day; seen from `today`.
838fn agenda_day(tr: &Translator, day: Date, today: Date, zone: &jiff::tz::TimeZone) -> Option<Day> {
839    let midnight = |d: Date| d.to_zoned(zone.clone()).ok().map(|z| ms(&z));
840    let (from, until) = (midnight(day)?, midnight(day.tomorrow().ok()?)?);
841    let name = day_short(tr, day);
842    let mut label = Vec::new();
843    if day > today.tomorrow().ok()? {
844        label.push(Label { until: midnight(day.yesterday().ok()?)?, text: name.clone() });
845    }
846    if day > today {
847        label.push(Label { until: from, text: said(tr, "home-card-tomorrow", &[("day", name.clone())]) });
848    }
849    label.push(Label { until, text: said(tr, "home-card-today", &[("day", name)]) });
850    Some(Day { from, until, label })
851}
852
853/// The coming appointments, from today to a month on: those not over yet,
854/// cancelled ones left out, nearest first; each under its day (Java's), in
855/// its calendar's colour. Details off, none named: "An event", its time.
856/// None without a calendar.
857/// A task's time block shows as the task it is, as in the agenda: its time,
858/// then "Task · " and its list (21 October 2026; `agenda::mark_tasks`, with
859/// `tasks`, the plan's).
860fn agenda_lines(m: &Moment, input: &AgendaInput, tasks: &[sioul_core::tasks::Task]) -> Option<AgendaLines> {
861    if !input.calendars {
862        return None;
863    }
864    let mut marked = input.events.clone();
865    sioul_core::agenda::mark_tasks(&mut marked, tasks);
866    let zone = m.now.time_zone().clone();
867    let today = m.now.date();
868    let now = m.now.timestamp().as_second();
869    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());
870    // An instant event still takes its minute.
871    let mut coming: Vec<&Occurrence> = marked.iter().filter(|e| !e.cancelled && e.end.max(e.start + 60) > now && e.start < end).collect();
872    // Nearest first, a day's whole-day events before its times (Java's days only go forward).
873    coming.sort_by(|a, b| (a.start, !a.all_day, &a.summary).cmp(&(b.start, !b.all_day, &b.summary)));
874    let events = coming
875        .into_iter()
876        .take(EVENTS)
877        .map(|e| Event {
878            key: e.key.clone(),
879            title: match e.summary.trim() {
880                _ if !m.details && !e.task.is_empty() => m.tr.text("home-card-task-plain", None),
881                _ if !m.details => m.tr.text("home-card-event-plain", None),
882                "" => m.tr.text("agenda-untitled", None),
883                title => title.to_string(),
884            },
885            time: match event_time(m.tr, e, &zone) {
886                time if e.task.is_empty() => time,
887                time => [time, said(m.tr, "agenda-task-of", &[("list", e.list.clone())])].into_iter().filter(|t| !t.is_empty()).collect::<Vec<_>>().join(" · "),
888            },
889            all_day: e.all_day,
890            start: e.start * 1000,
891            end: e.end.max(e.start + 60) * 1000,
892            color: colour(e.color.as_deref()),
893        })
894        .collect();
895    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();
896    Some(AgendaLines { empty: m.tr.text("home-card-agenda-empty", None), days, events })
897}
898
899/// Whether a step is for this time: work or admin time (or no hours set),
900/// never asleep, in a pause, in free time, at leisure or during a meal.
901fn time_for_a_step(mode: &Mode) -> bool {
902    matches!(mode.time, Time::Work | Time::Admin | Time::Several(_) | Time::Any) && !mode.sleeps() && !mode.paused() && !mode.free()
903}
904
905/// The next step at `at`, as Now would pick it then: only in work or admin
906/// time (or with no hours set), never asleep, in a pause, in free time, at
907/// leisure or during a meal; offices open or not then, "Not now" and the
908/// day's weather today, a new day starting clean. The page's own filters
909/// (one kind, one category) are the page's, not the card's.
910fn step_at(m: &Moment, input: &PlanInput, mode: &Mode, at: &Zoned) -> Option<Step> {
911    if !time_for_a_step(mode) {
912        return None;
913    }
914    let situation = quiet::Situation::now(m.config, m.overrides, m.blocks, at, m.tr, &input.loaded.projects);
915    let filter = Filter { quiet: situation.quiet_tasks(), ..Filter::default() };
916    let today = input.today.date == at.date().to_string();
917    let (weather, aside) = if today { (input.today.weather, input.today.aside.clone()) } else { (Weather::Clear, BTreeSet::new()) };
918    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 };
919    let now = taskview::now(&cx, weather, &aside);
920    let card = now.now?;
921    if m.details {
922        return Some(Step { title: card.title, why: now.why.first().cloned().unwrap_or_default(), uid: card.uid });
923    }
924    // Without its title, nor a reason that names another step: its date, else its length.
925    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 })
926}
927
928/// A code's kind, as the card's words name it.
929fn kind_word(kind: CodeKind) -> &'static str {
930    match kind {
931        CodeKind::Code => "code",
932        CodeKind::Password => "password",
933        CodeKind::PasswordReset => "reset",
934        CodeKind::SignInLink => "link",
935        CodeKind::Confirmation => "confirm",
936    }
937}
938
939/// The codes and links you asked sites for, still valid, newest first: each
940/// as the Porch's "Right now" says it, its site and the code, with the
941/// warning when its sender is not verified, until it expires (`porch::sent`
942/// and its validity, as the Porch hides it). Details off, one line without
943/// site or code: "A code waits on the Porch."
944fn codes(m: &Moment, input: &PorchInput) -> Vec<Code> {
945    let now = m.now.timestamp().as_second();
946    let mut found: Vec<(i64, Code, CodeKind)> = input
947        .items
948        .iter()
949        .filter(|t| t.lane == Lane::RightNow)
950        .filter_map(|t| {
951            let code = t.code.as_ref()?;
952            let lasts = i64::from(code.lasts_minutes()) * 60;
953            let sent = porch::sent(&t.card).unwrap_or(now);
954            let until = sent + lasts;
955            let line = m.tr.right_now(code.kind, t.card.sender(), t.trust, code.code.as_deref(), None);
956            let warning = if t.trust == Trust::Verified { String::new() } else { m.tr.text("right-now-unverified", None) };
957            (until > now).then(|| (sent, Code { line, warning, until: until * 1000 }, code.kind))
958        })
959        .collect();
960    found.sort_by_key(|(sent, _, _)| std::cmp::Reverse(*sent));
961    if m.details {
962        return found.into_iter().map(|(_, code, _)| code).collect();
963    }
964    let last = found.iter().map(|(_, code, _)| code.until).max();
965    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()
966}
967
968/// Today's doses in words: "Magnesium · 300 mg, 12:30", and once that it was
969/// not marked elsewhere is no longer known, "…: check before taking it."
970/// Never "missed", "late" or "not taken" (docs/health.md, "Knowing"). None
971/// with the details off: a medicine's name, even "a dose", says health to
972/// whoever shares the launcher.
973fn dose_lines(tr: &Translator, doses: &[Dose], details: bool) -> Vec<DoseLine> {
974    if !details {
975        return Vec::new();
976    }
977    doses
978        .iter()
979        .map(|d| {
980            let pairs = [("dose", d.name.clone()), ("time", d.time.clone())];
981            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 }
982        })
983        .collect()
984}
985
986/// How old the card is, said once it is: "at 14:05" until midnight,
987/// "yesterday at 14:05" the next day, a short date after.
988fn labels(tr: &Translator, made: &Zoned) -> Vec<Label> {
989    let time = made.strftime("%H:%M").to_string();
990    let first = midnight_after(made);
991    let second = first.as_ref().and_then(midnight_after);
992    vec![
993        Label { until: first.as_ref().map_or(0, ms), text: said(tr, "home-card-when-at", &[("time", time.clone())]) },
994        Label { until: second.as_ref().map_or(0, ms), text: said(tr, "home-card-when-yesterday", &[("time", time)]) },
995        Label { until: 0, text: tr.date(made, true) },
996    ]
997}
998
999/// What makes the Porch at `at` say what it says (`porch_at`): two frames
1000/// alike are made once. Its mail by the time it is for; open or closed, and
1001/// when it opens, from which day; resting after a pause.
1002fn porch_key(m: &Moment, mode: &Mode, at: &Zoned) -> String {
1003    let stamp = at.timestamp().as_second();
1004    let open = mode.quiet || m.config.windows.is_empty() || window::current(&m.config.windows, at).is_some();
1005    let opens = if open { 0 } else { window::next_opening(&m.config.windows, at).map_or(-1, |z| z.timestamp().as_second()) };
1006    format!("{}|{open}|{opens}|{}|{}", mode.time.id(), m.overrides.porch_rests(stamp), at.date())
1007}
1008
1009/// The card, from now to the end of tomorrow, with no weather, reminder,
1010/// call or line left on where you stopped.
1011#[cfg(test)]
1012fn snapshot(m: &Moment, porch: Option<&PorchInput>, plan: Option<&PlanInput>, agenda: Option<&AgendaInput>, doses: &[Dose]) -> Snapshot {
1013    snapshot_with(m, porch, plan, agenda, doses, &More::default())
1014}
1015
1016/// The card, from now to the end of tomorrow.
1017fn snapshot_with(m: &Moment, porch: Option<&PorchInput>, plan: Option<&PlanInput>, agenda: Option<&AgendaInput>, doses: &[Dose], more: &More) -> Snapshot {
1018    let set_up = m.config.every_account().next().is_some();
1019    let mut made: BTreeMap<String, PorchMade> = BTreeMap::new();
1020    // Each message written once, its place given to every frame that lists it.
1021    let mut mail: Vec<Mail> = Vec::new();
1022    let mut placed: BTreeMap<String, usize> = BTreeMap::new();
1023    let mut place = |message: &Mail| -> usize {
1024        if let Some(at) = placed.get(&message.key).filter(|_| !message.key.is_empty()) {
1025            return *at;
1026        }
1027        mail.push(message.clone());
1028        placed.insert(message.key.clone(), mail.len() - 1);
1029        mail.len() - 1
1030    };
1031    // Codes and doses as the matrix of what reaches you lets them come at each time.
1032    use sioul_core::attention::{Event, Kind as Own, Level, Now, Row};
1033    let attention = sioul_core::attention::Attention::of(m.config);
1034    let comes = |kind: Own, mode: &Mode| attention.level(Row::Own(kind), &Now::of(mode)) == Level::Now;
1035    // A reminder shows when its row lets it come or be seen then: not while it
1036    // waits for a later time (sleep, a pause, free time, work's while work rests).
1037    let reminders = reminder_lines(m.tr, more.reminders, m.details, m.now.timestamp().as_second());
1038    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);
1039    // Each call's line written once, in the Porch's words as seen from each frame's start.
1040    let mut calls: Vec<String> = Vec::new();
1041    let mut listed_calls: BTreeMap<String, Vec<String>> = BTreeMap::new();
1042    let mut call_at = |mode: &Mode, from: &Zoned| -> Vec<usize> {
1043        let Some(lines_at) = more.calls else { return Vec::new() };
1044        if mode.paused() {
1045            return Vec::new();
1046        }
1047        let lines = listed_calls.entry(format!("{}|{}|{}", kind(mode), mode.time.id(), from.date())).or_insert_with(|| lines_at(&Now::of(mode), from)).clone();
1048        let lines = if m.details || lines.is_empty() { lines } else { vec![m.tr.text("home-card-calls-plain", None)] };
1049        lines
1050            .into_iter()
1051            .map(|line| match calls.iter().position(|c| *c == line) {
1052                Some(at) => at,
1053                None => {
1054                    calls.push(line);
1055                    calls.len() - 1
1056                }
1057            })
1058            .collect()
1059    };
1060    let frames: Vec<Frame> = timeline(m)
1061        .into_iter()
1062        .enumerate()
1063        .map(|(index, (from, until, mode))| {
1064            // Asleep, in a pause or in free time, the Porch says nothing on the card.
1065            let rests = mode.sleeps() || mode.paused() || mode.free();
1066            let porch = if rests {
1067                None
1068            } else if !set_up {
1069                Some(PorchMade::said(m.tr.text("home-card-setup", None)))
1070            } else {
1071                porch.map(|p| made.entry(porch_key(m, &mode, &from)).or_insert_with(|| porch_at(m, p, &mode, &from)).clone())
1072            };
1073            let kind = kind(&mode);
1074            let step = plan.and_then(|p| step_at(m, p, &mode, &from));
1075            Frame {
1076                from: ms(&from),
1077                until: ms(&until),
1078                kind: kind.to_string(),
1079                status: status(m, &mode, &from),
1080                dnd: m.dnd.map(|d| d.line_in(kind, ms(&from), index == 0)).unwrap_or_default(),
1081                porch: porch.map(|p| PorchLines { line: p.line, mail: p.mail.iter().map(&mut place).collect() }),
1082                // The pause's card shows the pause alone: a code or a dose
1083                // then comes by its own notification, as its row says.
1084                codes: !mode.paused() && comes(Own::Codes, &mode),
1085                doses: !mode.paused() && comes(Own::Doses, &mode),
1086                events: !mode.paused(),
1087                reminders: reminders.iter().enumerate().filter(|(_, (_, work))| !mode.paused() && shows(*work, &mode)).map(|(at, _)| at).collect(),
1088                calls: call_at(&mode, &from),
1089                overlaps: !rests,
1090                stopped: !rests && time_for_a_step(&mode),
1091                note: if set_up || rests { String::new() } else { m.tr.text("home-card-setup", None) },
1092                step,
1093            }
1094        })
1095        .collect();
1096    let made = ms(m.now);
1097    let end = frames.last().map_or(made, |f| f.until);
1098    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) });
1099    Snapshot {
1100        v: VERSION,
1101        made,
1102        details: m.details,
1103        stale_after: made + FRESH_MS,
1104        stale: labels(m.tr, m.now),
1105        beyond: m.tr.text("home-card-beyond", None),
1106        words: Words { mail: m.tr.text("home-card-mail", None), agenda: m.tr.text("home-card-agenda", None) },
1107        today: today_lines(m, &more.weather, end),
1108        frames,
1109        mail,
1110        agenda: agenda.and_then(|a| agenda_lines(m, a, plan.map(|p| p.loaded.tasks.as_slice()).unwrap_or_default())),
1111        codes: porch.map(|p| codes(m, p)).unwrap_or_default(),
1112        doses: dose_lines(m.tr, doses, m.details),
1113        reminders: reminders.into_iter().map(|(line, _)| line).collect(),
1114        calls,
1115        overlaps: agenda.map(|a| overlap_lines(m, a)).unwrap_or_default(),
1116        stopped: stopped.unwrap_or_default(),
1117    }
1118}
1119
1120/// What a card says, its age aside: written again unchanged, Android need not draw it again.
1121fn content(snapshot: &Snapshot) -> String {
1122    let mut same = snapshot.clone();
1123    same.made = 0;
1124    same.stale_after = 0;
1125    same.stale.clear();
1126    if let Some(first) = same.frames.first_mut() {
1127        first.from = 0;
1128    }
1129    serde_json::to_string(&same).unwrap_or_default()
1130}
1131
1132// ---------------------------------------------------------------- written, and drawn
1133
1134/// Written on Android; on a computer only when SIOUL_HOME_CARD is set.
1135fn on() -> bool {
1136    cfg!(target_os = "android") || std::env::var_os("SIOUL_HOME_CARD").is_some()
1137}
1138
1139fn path() -> PathBuf {
1140    sioul_core::config::state_dir().join(FILE)
1141}
1142
1143/// The details are on unless "Details on the home screen" was unticked on this device.
1144pub(crate) fn details() -> bool {
1145    !crate::work::WorkState::load().flags.get(PLAIN).copied().unwrap_or(false)
1146}
1147
1148/// One writing at a time: the window's and a dose's alarm's.
1149static WRITING: Mutex<()> = Mutex::new(());
1150
1151/// The card's text in place of the one before, whole (written beside, then renamed).
1152fn write(text: &str) -> bool {
1153    let path = path();
1154    if let Some(parent) = path.parent() {
1155        let _ = std::fs::create_dir_all(parent);
1156    }
1157    let beside = path.with_extension("json.new");
1158    std::fs::write(&beside, text).and_then(|()| std::fs::rename(&beside, &path)).is_ok()
1159}
1160
1161/// Android asked to draw the card again: an explicit broadcast to Sioul's
1162/// own receiver (android/main.cpp, the call the sync apps are asked with).
1163fn draw() {
1164    #[cfg(target_os = "android")]
1165    // SAFETY: three zero-terminated texts, valid for the call.
1166    unsafe {
1167        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())
1168    };
1169}
1170
1171/// What the card is made from, as the window last had it.
1172static PORCH: Mutex<Option<Arc<PorchInput>>> = Mutex::new(None);
1173static PLAN: Mutex<Option<Arc<PlanInput>>> = Mutex::new(None);
1174
1175/// The card last written: what it says, when, and when its first frame ends (Unix seconds).
1176struct Written {
1177    content: String,
1178    made: i64,
1179    first_ends: i64,
1180}
1181
1182static WRITTEN: Mutex<Option<Written>> = Mutex::new(None);
1183
1184/// The Porch's messages the window just gathered, before the hours sort
1185/// them (`backend::compute`): the card follows.
1186pub(crate) fn porch_seen(items: &[Triaged], senders: &Senders, store: Option<&ProjectStore>) {
1187    if !on() {
1188        return;
1189    }
1190    *PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(PorchInput { items: items.to_vec(), senders: senders.clone(), store: store.cloned() }));
1191    soon(false);
1192}
1193
1194/// The plan the window just made (`work::show_work`): the card follows.
1195pub(crate) fn plan_seen(loaded: &Arc<Loaded>, plan: &Plan, today: &Today, spent: &BTreeMap<String, u32>, stopped: &BTreeMap<String, String>) {
1196    if !on() {
1197        return;
1198    }
1199    *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() }));
1200    soon(false);
1201}
1202
1203/// The events as last read for the card, and what the window hides of them.
1204static AGENDA: Mutex<Option<Arc<AgendaInput>>> = Mutex::new(None);
1205/// Removed or skipped in the window, waiting for "Undo" to pass: left out.
1206type Hidden = (BTreeSet<PathBuf>, BTreeSet<(PathBuf, i64)>);
1207static HIDDEN: Mutex<Option<Hidden>> = Mutex::new(None);
1208/// Read the calendars again before the next card: an event changed.
1209static AGENDA_AGAIN: AtomicBool = AtomicBool::new(true);
1210
1211/// The agenda read again by the window (`pim::show_pim`: a sync, an event
1212/// made, moved, removed or undone), with what it hides meanwhile: the card
1213/// reads the calendars again, and follows.
1214pub(crate) fn agenda_seen(removed: &BTreeSet<PathBuf>, skipped: &BTreeSet<(PathBuf, i64)>) {
1215    if !on() {
1216        return;
1217    }
1218    *HIDDEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some((removed.clone(), skipped.clone()));
1219    AGENDA_AGAIN.store(true, Ordering::SeqCst);
1220    soon(false);
1221}
1222
1223/// The events of today and the month to come, read from the calendars once,
1224/// then again when the window read the agenda again or the day changed.
1225fn agenda_input(now: &Zoned) -> Option<Arc<AgendaInput>> {
1226    let mut kept = AGENDA.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1227    let again = AGENDA_AGAIN.swap(false, Ordering::SeqCst);
1228    if !again && let Some(input) = kept.as_ref().filter(|a| a.read_on == now.date()) {
1229        return Some(Arc::clone(input));
1230    }
1231    let midnight = now.date().to_zoned(now.time_zone().clone()).ok()?.timestamp().as_second();
1232    let reach = midnight + (HORIZON_DAYS + 1) * 86_400 + 3_600;
1233    let (removed, skipped) = HIDDEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone().unwrap_or_default();
1234    // As every agenda reads them (`pim::agenda_read`); their tasks marked with the plan's (`agenda_lines`).
1235    let events = crate::pim::agenda_read(midnight, reach, &removed, &skipped);
1236    let calendars = !sioul_core::vdir::collections(sioul_core::vdir::Kind::Calendars).is_empty();
1237    let aside = sioul_core::overlaps::SetAside::load(&sioul_core::overlaps::SetAside::default_path()).keys;
1238    let input = Arc::new(AgendaInput { events, calendars, read_on: now.date(), aside });
1239    *kept = Some(Arc::clone(&input));
1240    Some(input)
1241}
1242
1243/// The reminders as last gathered (`remind::tick`, each minute), the events' left out.
1244static REMINDED: Mutex<Option<Arc<Vec<Reminder>>>> = Mutex::new(None);
1245
1246/// The reminders the window just gathered, told or not (`reminders::gather`):
1247/// the card follows when those it may say changed.
1248pub(crate) fn reminders_seen(all: &[Reminder]) {
1249    if !on() {
1250        return;
1251    }
1252    use sioul_core::reminders::Kind;
1253    let kept: Vec<Reminder> = all.iter().filter(|r| !matches!(r.kind, Kind::Event | Kind::Before | Kind::Alarm)).cloned().collect();
1254    let mut seen = REMINDED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1255    if seen.as_deref() == Some(&kept) {
1256        return;
1257    }
1258    *seen = Some(Arc::new(kept));
1259    drop(seen);
1260    soon(false);
1261}
1262
1263/// A forecast fetched, or the place changed (`backend::update_weather`): the card follows.
1264pub(crate) fn weather_seen() {
1265    if on() {
1266        soon(false);
1267    }
1268}
1269
1270/// The weather as the card says it: the place chosen, the forecast kept.
1271fn weather_input() -> WeatherInput {
1272    let config = load_config();
1273    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();
1274    let forecast = place.then(|| std::fs::read_to_string(crate::backend::weather_cache()).ok().and_then(|t| serde_json::from_str(&t).ok())).flatten();
1275    WeatherInput { place, forecast }
1276}
1277
1278/// The window's minute: the card written again when its first frame ended,
1279/// or five minutes went by (its age said afresh).
1280pub(crate) fn tick() {
1281    if !on() {
1282        return;
1283    }
1284    let now = Timestamp::now().as_second();
1285    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);
1286    if due {
1287        soon(false);
1288    }
1289}
1290
1291/// Sioul put away, or a setting of the card changed: written now, and drawn.
1292pub(crate) fn now() {
1293    if on() {
1294        soon(true);
1295    }
1296}
1297
1298/// The shared do-not-disturb's moment, as last seen in this process.
1299static DND: Mutex<Option<serde_json::Value>> = Mutex::new(None);
1300
1301/// The do-not-disturb on every device, as its module reads it
1302/// (`everywhere::moment`: {on, why, until, until_at, line…}), handed after
1303/// each of its changes, here or from another device, and when first read in
1304/// a process. The same as last time: nothing. With the window's Porch and
1305/// plan, the card is made again; without (a receiver alone), the card on disk
1306/// gets its do-not-disturb lines alone, the rest as Sioul last saw it.
1307pub(crate) fn dnd_seen(moment: serde_json::Value) {
1308    if !on() {
1309        return;
1310    }
1311    {
1312        let mut kept = DND.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1313        if kept.as_ref() == Some(&moment) {
1314            return;
1315        }
1316        *kept = Some(moment.clone());
1317    }
1318    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();
1319    if window {
1320        soon(true);
1321        return;
1322    }
1323    let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1324    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 };
1325    if patch_dnd(&mut card, Dnd::of(&moment).as_ref(), Timestamp::now().as_millisecond()) && write(&card.to_string()) {
1326        draw();
1327    }
1328}
1329
1330/// The do-not-disturb lines of a card written before, said again: each
1331/// frame from now on as `Dnd::line_in` says it, a frame cut where it ends
1332/// (Unix ms `now`). Whether anything changed.
1333fn patch_dnd(card: &mut serde_json::Value, dnd: Option<&Dnd>, now: i64) -> bool {
1334    let Some(frames) = frames_of(card) else { return false };
1335    let before = frames.clone();
1336    // Cut the frame its end falls in: its line goes there.
1337    if let Some(end) = dnd.and_then(|d| d.until).filter(|u| *u > 0).map(|u| u * 1000)
1338        && 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))
1339    {
1340        let mut after = frames[at].clone();
1341        after["from"] = end.into();
1342        frames[at]["until"] = end.into();
1343        frames.insert(at + 1, after);
1344    }
1345    // The frame for now: the one now falls in, else the first (the clock set back).
1346    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);
1347    for (index, frame) in frames.iter_mut().enumerate() {
1348        let (kind, from) = (frame["kind"].as_str().unwrap_or_default().to_string(), frame["from"].as_i64().unwrap_or(0));
1349        frame["dnd"] = dnd.map(|d| d.line_in(&kind, from, index == current)).unwrap_or_default().into();
1350    }
1351    *frames != before
1352}
1353
1354/// A written card's frames: "times" from version 3, "frames" before.
1355fn frames_of(card: &mut serde_json::Value) -> Option<&mut Vec<serde_json::Value>> {
1356    let key = if card["v"].as_u64().is_some_and(|v| v >= 3) { "times" } else { "frames" };
1357    card[key].as_array_mut()
1358}
1359
1360/// Asked again while being made: once more after. Drawn when asked so once.
1361static AGAIN: AtomicBool = AtomicBool::new(false);
1362static RUNNING: AtomicBool = AtomicBool::new(false);
1363static TELL: AtomicBool = AtomicBool::new(false);
1364
1365/// The card made on a thread of its own, several asks in a row making it once.
1366fn soon(tell: bool) {
1367    if tell {
1368        TELL.store(true, Ordering::SeqCst);
1369    }
1370    AGAIN.store(true, Ordering::SeqCst);
1371    if RUNNING.swap(true, Ordering::SeqCst) {
1372        return;
1373    }
1374    std::thread::spawn(|| {
1375        loop {
1376            while AGAIN.swap(false, Ordering::SeqCst) {
1377                let tell = TELL.swap(false, Ordering::SeqCst);
1378                // A panic on bad data leaves the next card free to be made.
1379                let _ = std::panic::catch_unwind(|| refresh(tell));
1380            }
1381            RUNNING.store(false, Ordering::SeqCst);
1382            if !AGAIN.load(Ordering::SeqCst) || RUNNING.swap(true, Ordering::SeqCst) {
1383                break;
1384            }
1385        }
1386    });
1387}
1388
1389/// The card made from what the window last had, written, and drawn when it
1390/// changed (or `tell`). Not before the window has both the Porch and the
1391/// plan in this process: a card made without them would lose its mail and
1392/// its step until Sioul comes back (put away at once after it opened, its
1393/// pages wait); the card written before stays, its age said.
1394fn refresh(tell: bool) {
1395    let porch = PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
1396    let plan = PLAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
1397    let (Some(porch), Some(plan)) = (porch, plan) else { return };
1398    let now = Zoned::now();
1399    let config = load_config();
1400    let overrides = Overrides::load(&Overrides::default_path());
1401    let blocks = crate::hours::blocks(&now);
1402    let details = details();
1403    let doses = if details { crate::health::card_doses(&now) } else { Vec::new() };
1404    let dnd = DND.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(Dnd::of);
1405    let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: tr(), details, dnd: dnd.as_ref() };
1406    let agenda = agenda_input(&now);
1407    let reminded = REMINDED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone().unwrap_or_default();
1408    let calls = crate::calls::for_card();
1409    let stopped = sioul_core::stopped::Stopped::load(&sioul_core::stopped::Stopped::default_path()).map(|s| s.text);
1410    let more = More { weather: weather_input(), reminders: &reminded, calls: calls.as_deref(), stopped };
1411    let card = snapshot_with(&moment, Some(&porch), Some(&plan), agenda.as_deref(), &doses, &more);
1412    let said = content(&card);
1413    let Ok(text) = serde_json::to_string(&card) else { return };
1414    let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1415    let mut written = WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1416    let changed = written.as_ref().is_none_or(|w| w.content != said);
1417    if !write(&text) {
1418        return;
1419    }
1420    let first_ends = card.frames.first().map_or(0, |f| f.until / 1000);
1421    *written = Some(Written { content: said, made: now.timestamp().as_second(), first_ends });
1422    drop(written);
1423    if changed || tell {
1424        draw();
1425    }
1426}
1427
1428/// A dose marked or reminded outside the window (a dose's alarm, its "Taken":
1429/// `alarms`): the card's doses written again, the rest as Sioul last saw it.
1430/// No card yet, nothing.
1431pub(crate) fn doses_changed() {
1432    if !on() {
1433        return;
1434    }
1435    let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1436    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 };
1437    let details = details();
1438    let now = Zoned::now();
1439    let doses = if details { crate::health::card_doses(&now) } else { Vec::new() };
1440    let Ok(lines) = serde_json::to_value(dose_lines(tr(), &doses, details)) else { return };
1441    if card["doses"] == lines {
1442        return;
1443    }
1444    card["doses"] = lines;
1445    if write(&card.to_string()) {
1446        draw();
1447    }
1448}
1449
1450/// New mail fetched outside the window (the phone's background service,
1451/// `steps`, while Sioul is closed or frozen): the card's messages and codes
1452/// made again from the Porch as it is now, the rest (its lines, the step, the
1453/// doses, the agenda) as Sioul last saw it. In the window's process the
1454/// window's own Porch follows (`porch_seen`): nothing here. No card yet, nothing.
1455pub(crate) fn mail_came() {
1456    if !on() || PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_some() {
1457        return;
1458    }
1459    let config = load_config();
1460    let now = Zoned::now();
1461    // The Porch as the window gathers it (`backend::World`): the projects and their ties, the senders let in.
1462    let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
1463    let store = config.notes_root_path().and_then(|root| ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
1464    let known = porch::KnownSenders::load(&config.known_senders_path());
1465    let senders = Senders::load(&config);
1466    let state = sioul_core::state::PorchState::load(&sioul_core::state::PorchState::default_path());
1467    let items = porch::gather(&config.mail_sources(), store.as_ref(), &known, &senders, &state, now.timestamp().as_second());
1468    let input = PorchInput { items, senders, store };
1469    let overrides = Overrides::load(&Overrides::default_path());
1470    let blocks = crate::hours::blocks(&now);
1471    let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: tr(), details: details(), dnd: None };
1472    let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1473    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 };
1474    if patch_mail(&mut card, &moment, &input) && write(&card.to_string()) {
1475        draw();
1476    }
1477}
1478
1479/// The messages and codes of a card written before, made again from `input`:
1480/// each frame's Porch as `snapshot` makes it at the frame's start; the frames
1481/// that say nothing of the Porch (asleep, a pause, free time) left so. Whether
1482/// anything changed.
1483fn patch_mail(card: &mut serde_json::Value, m: &Moment, input: &PorchInput) -> bool {
1484    if card["v"] != VERSION {
1485        return false;
1486    }
1487    let before = card.clone();
1488    let hours = m.config.week_hours();
1489    let zone = m.now.time_zone().clone();
1490    let mut made: BTreeMap<String, PorchMade> = BTreeMap::new();
1491    let mut mail: Vec<Mail> = Vec::new();
1492    let mut placed: BTreeMap<String, usize> = BTreeMap::new();
1493    let Some(frames) = frames_of(card) else { return false };
1494    for frame in frames.iter_mut().filter(|f| f["porch"].is_object()) {
1495        let Some(from) = frame["from"].as_i64().and_then(|at| Timestamp::from_millisecond(at).ok()).map(|t| t.to_zoned(zone.clone())) else { continue };
1496        let mode = quiet::mode(&hours, &m.config.time_off, m.overrides, m.blocks, &from);
1497        let porch = made.entry(porch_key(m, &mode, &from)).or_insert_with(|| porch_at(m, input, &mode, &from)).clone();
1498        let at: Vec<usize> = porch
1499            .mail
1500            .iter()
1501            .map(|message| match placed.get(&message.key).filter(|_| !message.key.is_empty()) {
1502                Some(at) => *at,
1503                None => {
1504                    mail.push(message.clone());
1505                    placed.insert(message.key.clone(), mail.len() - 1);
1506                    mail.len() - 1
1507                }
1508            })
1509            .collect();
1510        let Ok(lines) = serde_json::to_value(PorchLines { line: porch.line, mail: at }) else { return false };
1511        frame["porch"] = lines;
1512    }
1513    let (Ok(mail), Ok(codes)) = (serde_json::to_value(mail), serde_json::to_value(codes(m, input))) else { return false };
1514    card["mail"] = mail;
1515    card["codes"] = codes;
1516    *card != before
1517}
1518
1519/// A tap on the card, kept by Java (HomeCardOpener.java) and taken once, as
1520/// main.qml's `openThing` takes it {kind, uri, key}: the Porch; a message, on
1521/// the Porch in its Reader ("card-mail", its file); Now on a step ("now", the
1522/// task's address); the Agenda; an event in it ("event", its file); what a
1523/// reminder is about (its task, budget, paper or contract). One older than
1524/// five minutes is let go.
1525pub(crate) fn opened() -> Option<(&'static str, String, String)> {
1526    let path = sioul_core::config::state_dir().join(OPENED);
1527    let text = std::fs::read_to_string(&path).ok()?;
1528    let _ = std::fs::remove_file(&path);
1529    tap(&text, Timestamp::now().as_millisecond())
1530}
1531
1532/// A tap as Java writes it: {"open": "porch" | "now" | "mail" | "agenda" |
1533/// "event" | "reminder", "uid", "key", "at" (Unix ms)}. A message or an event
1534/// without its file opens its page; a reminder, what it is about ("key": its
1535/// address, `sioul:task/…`), else the Porch. The file is the window's to
1536/// check, as for any link.
1537fn tap(text: &str, now: i64) -> Option<(&'static str, String, String)> {
1538    let tap: serde_json::Value = serde_json::from_str(text).ok()?;
1539    let at = tap["at"].as_i64()?;
1540    if now - at > TAP_KEPT_MS || at - now > 60_000 {
1541        return None;
1542    }
1543    let key = tap["key"].as_str().unwrap_or_default().to_string();
1544    match tap["open"].as_str()? {
1545        "porch" => Some(("porch", String::new(), String::new())),
1546        "now" => Some(("now", tap["uid"].as_str().filter(|u| !u.is_empty()).map(sioul_core::links::task_uri).unwrap_or_default(), String::new())),
1547        "mail" if !key.is_empty() => Some(("card-mail", String::new(), key)),
1548        "mail" => Some(("porch", String::new(), String::new())),
1549        "event" if !key.is_empty() => Some(("event", String::new(), key)),
1550        "event" | "agenda" => Some(("agenda", String::new(), String::new())),
1551        "reminder" => Some(reminder_opens(&key)),
1552        _ => None,
1553    }
1554}
1555
1556/// What a reminder's tap opens, as a reminder's notification does on a
1557/// computer (`remind::tick`): its task, its budget, its paper, its contract;
1558/// anything else, the Porch.
1559fn reminder_opens(target: &str) -> (&'static str, String, String) {
1560    if let Some(uid) = target.strip_prefix("sioul:task/") {
1561        ("task", target.to_string(), uid.to_string())
1562    } else if target.starts_with("sioul:budget/") {
1563        ("budget", target.to_string(), String::new())
1564    } else if let Some(id) = target.strip_prefix("sioul:paper/") {
1565        ("paper", target.to_string(), id.to_string())
1566    } else if let Some(id) = target.strip_prefix("sioul:contract/") {
1567        ("contract", target.to_string(), id.to_string())
1568    } else {
1569        ("porch", String::new(), String::new())
1570    }
1571}
1572
1573/// "Details on the home screen", for Settings (on a phone): its row, in `group` and `section`.
1574pub(crate) fn setting(group: &str, section: &str, on: bool) -> sioul_core::settings::Setting {
1575    sioul_core::settings::Setting {
1576        key: "home_card_details".into(),
1577        kind: sioul_core::settings::Kind::Bool,
1578        label: tr().text("set-home-card-details", None),
1579        help: tr().text("set-home-card-details-help", None),
1580        value: sioul_core::config::SettingValue::Bool(on),
1581        choices: Vec::new(),
1582        rows: Vec::new(),
1583        min: 0.0,
1584        max: 0.0,
1585        step: 1.0,
1586        unit: String::new(),
1587        group: group.to_string(),
1588        section: section.to_string(),
1589    }
1590}
1591
1592/// Whether the setting shows: on a phone (or with SIOUL_HOME_CARD set).
1593pub(crate) fn has_setting() -> bool {
1594    on()
1595}
1596
1597#[cfg(test)]
1598mod tests {
1599    use super::*;
1600    use sioul_core::card::Card;
1601    use sioul_core::codes::OneTimeCode;
1602    use sioul_core::config::Priority;
1603    use sioul_core::needs::{Days, Needs};
1604    use sioul_core::plan::Settings;
1605    use sioul_core::reminders;
1606    use sioul_core::tasks::Task;
1607
1608    /// Monday to Friday 09:00–17:00 at work, Tuesday 17:30–18:30 for admin.
1609    fn config() -> Config {
1610        toml::from_str(
1611            "[[account]]\nid = \"me\"\naddress = \"me@example.org\"\n\n\
1612             [[window]]\nday = \"monday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1613             [[window]]\nday = \"tuesday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1614             [[window]]\nday = \"wednesday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1615             [[window]]\nday = \"thursday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1616             [[window]]\nday = \"friday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1617             [[window]]\nday = \"tuesday\"\nstart = \"17:30\"\nend = \"18:30\"\nkind = \"admin\"\n",
1618        )
1619        .unwrap()
1620    }
1621
1622    /// Three meals and the night (bed 23:00, winding down from 22:00, waking 07:00).
1623    fn blocks(now: &Zoned) -> Blocks {
1624        let needs = Needs { meals_on: true, sleep_on: true, ..Needs::default() };
1625        Blocks::of(&needs, &Days::default(), &[], now)
1626    }
1627
1628    fn at(text: &str) -> Zoned {
1629        text.parse().unwrap()
1630    }
1631
1632    /// A message come to the account "me", not read yet (in `new/`), its file named after its sender and subject.
1633    fn message(from: &str, subject: &str, lane: Lane) -> Triaged {
1634        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");
1635        let mut card = Card::from_bytes(raw.as_bytes()).unwrap();
1636        let name: String = format!("{from}-{subject}").chars().filter(char::is_ascii_alphanumeric).collect();
1637        card.path = Some(PathBuf::from(format!("/mail/me/INBOX/new/1759600000.{name}.sioul")));
1638        card.account = Some("me".into());
1639        Triaged { card, lane, trust: Trust::Verified, code: None, reasons: Vec::new(), priority: Priority::Average, checks: None, assessment: None }
1640    }
1641
1642    /// The same, come at `sent` (Unix seconds), read already when `read` (its file in `cur/`, flagged S).
1643    fn sent_at(from: &str, subject: &str, sent: i64, read: bool) -> Triaged {
1644        let mut letter = message(from, subject, Lane::People);
1645        letter.card.date = Some(sent);
1646        if read {
1647            let name = letter.card.path.as_ref().unwrap().file_name().unwrap().to_string_lossy().to_string();
1648            letter.card.path = Some(PathBuf::from(format!("/mail/me/INBOX/cur/{name}:2,S")));
1649        }
1650        letter
1651    }
1652
1653    /// Four letters from people you know (safe senders: in every time but sleep), one code.
1654    fn porch_input(now: &Zoned) -> PorchInput {
1655        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")]
1656            .into_iter()
1657            .map(|(from, subject)| message(from, subject, Lane::People))
1658            .collect();
1659        let mut code = message("La Banque <no-reply@banque.example>", "Votre code", Lane::RightNow);
1660        code.code = Some(OneTimeCode { kind: CodeKind::Code, code: Some("482913".into()), expires_minutes: Some(10) });
1661        // Sent two minutes ago.
1662        code.card.date = Some(now.timestamp().as_second() - 120);
1663        items.push(code);
1664        // People you know, marked safe: their mail comes at every time but sleep (the usual matrix).
1665        let senders = Senders { safe: sioul_core::porch::SenderList::parse("marie@example.org\npaul@example.org\nlea@example.org\ntom@example.org\n"), ..Senders::default() };
1666        PorchInput { items, senders, store: None }
1667    }
1668
1669    fn task(uid: &str, title: &str) -> Task {
1670        Task { uid: uid.into(), title: title.into(), estimate: 15, ..Task::default() }
1671    }
1672
1673    fn plan_input(now: &Zoned) -> PlanInput {
1674        let mut form = task("form", "Call the CAF about the housing aid");
1675        form.due = "2026-10-30".into();
1676        // For work: work's hours bring it, admin's (they are set) do not.
1677        form.area = "work".into();
1678        let tasks = vec![form];
1679        let plan = sioul_core::plan::plan(&tasks, now.date(), &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
1680        PlanInput { loaded: Arc::new(Loaded { tasks, ..Loaded::default() }), plan, today: Today { date: now.date().to_string(), ..Today::default() }, spent: BTreeMap::new(), stopped: BTreeMap::new() }
1681    }
1682
1683    struct Made {
1684        card: Snapshot,
1685    }
1686
1687    impl Made {
1688        /// The frame showing at `when`.
1689        fn at(&self, when: &str) -> &Frame {
1690            let t = at(when).timestamp().as_millisecond();
1691            self.card.frames.iter().find(|f| f.from <= t && t < f.until).unwrap_or_else(|| panic!("no frame at {when}: {:#?}", self.card.frames))
1692        }
1693    }
1694
1695    /// The calendars' events, as the card reads them (`agenda_input`), from
1696    /// `(title, start, end, all day, colour, cancelled)` in Paris; one
1697    /// calendar, "Personal".
1698    fn events_of(events: &[(&str, &str, &str, bool, Option<&str>, bool)]) -> AgendaInput {
1699        let events = events
1700            .iter()
1701            .enumerate()
1702            .map(|(n, (title, start, end, all_day, color, cancelled))| Occurrence {
1703                key: format!("/calendars/me/personal/event-{n}.ics"),
1704                summary: title.to_string(),
1705                start: at(start).timestamp().as_second(),
1706                end: at(end).timestamp().as_second(),
1707                all_day: *all_day,
1708                calendar: "Personal".into(),
1709                color: color.map(str::to_string),
1710                cancelled: *cancelled,
1711                ..Occurrence::default()
1712            })
1713            .collect();
1714        AgendaInput { events, calendars: true, read_on: Date::constant(2026, 10, 5), aside: BTreeSet::new() }
1715    }
1716
1717    /// The owner's dated task of 21 October 2026 showed in the computer's
1718    /// agenda and not in the phone's nor on the home card. The window's agenda
1719    /// and the phone's are one page; it and the card read the same items
1720    /// (`pim::agenda_read`), mark the same tasks (`agenda::mark_tasks`), and
1721    /// none reads a task list's own tasks: a task shows by its time block, as
1722    /// a task. An event slipped into a list shows in all three (F3).
1723    #[test]
1724    fn every_agenda_reads_the_same_items() {
1725        use sioul_core::vdir::{self, Kind};
1726        let now = Zoned::now();
1727        let zone = now.time_zone().clone();
1728        let tomorrow = now.date().tomorrow().unwrap();
1729        let utc = |hour: i8| tomorrow.at(hour, 0, 0, 0).to_zoned(zone.clone()).unwrap().with_time_zone(jiff::tz::TimeZone::UTC).strftime("%Y%m%dT%H%M%SZ").to_string();
1730        let event = |uid: &str, hour: i8, more: &str| format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTART:{}\r\nDTEND:{}\r\nSUMMARY:{uid}\r\n{more}END:VEVENT\r\nEND:VCALENDAR\r\n", utc(hour), utc(hour + 1));
1731        let calendar = vdir::create(Kind::Calendars, vdir::LOCAL, "Kinds agenda", None, &["VEVENT"]).unwrap();
1732        let list = vdir::create(Kind::Calendars, vdir::LOCAL, "Kinds tasks", None, &["VTODO"]).unwrap();
1733        std::fs::write(calendar.dir.join("dentist.ics"), event("dentist", 9, "")).unwrap();
1734        std::fs::write(calendar.dir.join("block.ics"), event("block", 11, "X-SIOUL-TASK:t-agenda\r\n")).unwrap();
1735        std::fs::write(calendar.dir.join("orphan.ics"), event("orphan", 13, "X-SIOUL-TASK:gone\r\n")).unwrap();
1736        std::fs::write(list.dir.join("stray.ics"), event("stray", 15, "")).unwrap();
1737        std::fs::write(list.dir.join("task.ics"), format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t-agenda\r\nSUMMARY:Call\r\nDTSTART:{}\r\nEND:VTODO\r\nEND:VCALENDAR\r\n", utc(16))).unwrap();
1738        let tasks = sioul_core::tasks::all(&zone);
1739        assert!(tasks.iter().any(|t| t.uid == "t-agenda" && t.list == "Kinds tasks"));
1740        let ours = |key: &str| std::path::Path::new(key).starts_with(&calendar.dir) || std::path::Path::new(key).starts_with(&list.dir);
1741
1742        // The window's and the phone's agenda (`pim::show_pim`).
1743        let midnight = now.date().to_zoned(zone.clone()).unwrap().timestamp().as_second();
1744        let reach = midnight + (HORIZON_DAYS + 1) * 86_400 + 3_600;
1745        let none = (BTreeSet::new(), BTreeSet::new());
1746        let items = crate::pim::agenda_items(midnight, reach, &none.0, &none.1, &tasks);
1747        let page = sioul_core::view::agenda(&items, now.date(), HORIZON_DAYS + 1, &Translator::new("en"), &zone);
1748        let mut window: Vec<(String, String, String)> = page.days.iter().flat_map(|d| d.events.iter()).filter(|e| ours(&e.key)).map(|e| (e.summary.clone(), e.task.clone(), e.list.clone())).collect();
1749        window.sort();
1750        assert_eq!(window, [("block".to_string(), "t-agenda".to_string(), "Kinds tasks".to_string()), ("dentist".into(), String::new(), String::new()), ("orphan".into(), String::new(), String::new()), ("stray".into(), String::new(), String::new())]);
1751
1752        // The home card, from the same read.
1753        let config = config();
1754        let tr = Translator::new("en");
1755        let blocks = blocks(&now);
1756        let overrides = Overrides::default();
1757        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
1758        let input = AgendaInput { events: crate::pim::agenda_read(midnight, reach, &none.0, &none.1), calendars: true, read_on: now.date(), aside: BTreeSet::new() };
1759        let card = agenda_lines(&moment, &input, &tasks).unwrap();
1760        let mut home: Vec<String> = card.events.iter().filter(|e| ours(&e.key)).map(|e| e.title.clone()).collect();
1761        home.sort();
1762        assert_eq!(home, ["block", "dentist", "orphan", "stray"], "the same items");
1763        let block = card.events.iter().find(|e| e.title == "block").unwrap();
1764        assert!(block.time.ends_with("Task · Kinds tasks"), "marked as a task, with its list: {}", block.time);
1765        assert!(!card.events.iter().find(|e| e.title == "orphan").unwrap().time.contains("Task"), "a block of a task not here is an event");
1766        let _ = std::fs::remove_dir_all(&calendar.dir);
1767        let _ = std::fs::remove_dir_all(&list.dir);
1768    }
1769
1770    /// A dentist tomorrow, a whole day on Monday next week, a trip over three days, a call that was cancelled.
1771    fn some_events() -> AgendaInput {
1772        events_of(&[
1773            ("Dentist", "2026-10-06T11:00[Europe/Paris]", "2026-10-06T12:00[Europe/Paris]", false, Some("#4C6B5CFF"), false),
1774            ("Choir", "2026-10-05T19:00[Europe/Paris]", "2026-10-05T20:30[Europe/Paris]", false, None, false),
1775            ("Neighbours' party", "2026-10-12T00:00[Europe/Paris]", "2026-10-13T00:00[Europe/Paris]", true, Some("#b07d3f"), false),
1776            ("Trip to the sea", "2026-10-14T00:00[Europe/Paris]", "2026-10-17T00:00[Europe/Paris]", true, None, false),
1777            ("Night train", "2026-10-20T22:30[Europe/Paris]", "2026-10-21T06:45[Europe/Paris]", false, None, false),
1778            ("A call", "2026-10-07T09:00[Europe/Paris]", "2026-10-07T09:30[Europe/Paris]", false, None, true),
1779        ])
1780    }
1781
1782    fn make(now: &str, overrides: &Overrides, details: bool, language: &str, doses: &[Dose]) -> Made {
1783        let now = at(now);
1784        let config = config();
1785        let tr = Translator::new(language);
1786        let blocks = blocks(&now);
1787        let moment = Moment { now: &now, config: &config, overrides, blocks: &blocks, tr: &tr, details, dnd: None };
1788        let porch = porch_input(&now);
1789        let plan = plan_input(&now);
1790        Made { card: snapshot(&moment, Some(&porch), Some(&plan), Some(&some_events()), doses) }
1791    }
1792
1793    impl Made {
1794        /// The messages a frame lists: their senders.
1795        fn senders(&self, frame: &Frame) -> Vec<&str> {
1796            frame.porch.iter().flat_map(|p| p.mail.iter()).map(|at| self.card.mail[*at].sender.as_str()).collect()
1797        }
1798    }
1799
1800    /// Every word the card can show in `frame`, in one text: its lines, its
1801    /// messages, its step, the agenda's days and events.
1802    fn words(card: &Snapshot, frame: &Frame) -> String {
1803        let mut out = vec![frame.status.clone(), frame.note.clone(), card.today.line.clone()];
1804        out.extend(card.today.date.iter().map(|l| l.text.clone()));
1805        if let Some(p) = &frame.porch {
1806            out.push(p.line.clone());
1807            for mail in p.mail.iter().map(|at| &card.mail[*at]) {
1808                out.extend([mail.sender.clone(), mail.subject.clone(), mail.text.clone()]);
1809                out.extend(mail.when.iter().map(|l| l.text.clone()));
1810            }
1811        }
1812        if let Some(s) = &frame.step {
1813            out.extend([s.title.clone(), s.why.clone()]);
1814        }
1815        if let Some(agenda) = card.agenda.as_ref().filter(|_| frame.events) {
1816            out.push(agenda.empty.clone());
1817            out.extend(agenda.days.iter().flat_map(|d| d.label.iter()).map(|l| l.text.clone()));
1818            out.extend(agenda.events.iter().flat_map(|e| [e.title.clone(), e.time.clone()]));
1819        }
1820        out.extend(card.words.mail.lines().chain(card.words.agenda.lines()).map(str::to_string));
1821        out.join("\n")
1822    }
1823
1824    #[test]
1825    fn each_time_says_its_own() {
1826        // Monday 5 October 2026, 10:00 in Paris: work until 12:10 (lunch is got ready from then).
1827        let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "en", &[]);
1828        let work = made.at("2026-10-05T10:00[Europe/Paris]");
1829        assert_eq!((work.kind.as_str(), work.status.as_str()), ("work", "Work until 12:10."));
1830        let porch = work.porch.as_ref().unwrap();
1831        // Four letters and a code came: the letters listed, no sentence above them (the
1832        // code has its own line); come at the same time, in the Porch's order.
1833        assert_eq!(porch.line, "");
1834        assert_eq!(made.senders(work), ["Marie Dupont", "Paul", "Léa", "Tom"]);
1835        let first = &made.card.mail[porch.mail[0]];
1836        assert_eq!((first.subject.as_str(), first.text.as_str()), ("Dinner on Saturday", "Hello, the text on several lines."));
1837        let step = work.step.as_ref().unwrap();
1838        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"));
1839        assert!(work.codes && work.doses);
1840        // The code, with its site, as "Right now" says it, until it expires (ten minutes from its sending).
1841        assert_eq!(made.card.codes.len(), 1);
1842        assert_eq!(made.card.codes[0].line, "Code from La Banque (verified): 482913");
1843        assert_eq!(made.card.codes[0].warning, "");
1844        assert_eq!(made.card.codes[0].until, at("2026-10-05T10:08[Europe/Paris]").timestamp().as_millisecond());
1845        // Lunch: its line, no step; the Porch as at meals (people you know are safe: they come).
1846        let meal = made.at("2026-10-05T12:30[Europe/Paris]");
1847        assert_eq!((meal.kind.as_str(), meal.status.as_str()), ("meals", "Meal until 13:00."));
1848        assert!(meal.step.is_none());
1849        // Work again, then leisure in the evening: no step.
1850        assert_eq!(made.at("2026-10-05T15:00[Europe/Paris]").status, "Work until 17:00.");
1851        let evening = made.at("2026-10-05T17:30[Europe/Paris]");
1852        assert_eq!((evening.kind.as_str(), evening.status.as_str()), ("leisure", "Leisure until 19:00: what you enjoy."));
1853        assert!(evening.step.is_none() && evening.porch.is_some());
1854        // Winding down from 22:00, then the night from bedtime: the sleep's line alone, as the window says it;
1855        // doses remind during sleep by default, and codes you asked for come at once asleep too
1856        // (docs/attention.md, Q6; before, they waited on the Porch).
1857        assert_eq!(made.at("2026-10-05T22:15[Europe/Paris]").status, "Winding down: nothing disturbs until tomorrow at 07:00.");
1858        let night = made.at("2026-10-05T23:30[Europe/Paris]");
1859        assert_eq!((night.kind.as_str(), night.status.as_str()), ("sleep", "Sleep: nothing disturbs until tomorrow at 07:00."));
1860        assert!(night.porch.is_none() && night.step.is_none() && night.codes && night.doses);
1861        // Past midnight "tomorrow" is today: a frame ends at each midnight.
1862        assert!(made.card.frames.iter().any(|f| f.from == at("2026-10-06T00:00[Europe/Paris]").timestamp().as_millisecond()));
1863        assert_eq!(made.at("2026-10-06T05:00[Europe/Paris]").status, "Sleep: nothing disturbs until 07:00.");
1864        // Tuesday: work, then admin hours from 17:30, with no step: the CAF is work's.
1865        let admin = made.at("2026-10-06T17:45[Europe/Paris]");
1866        assert_eq!((admin.kind.as_str(), admin.status.as_str()), ("admin", "Admin time until 18:30: offices, bills, letters."));
1867        assert!(admin.step.is_none());
1868        assert!(made.at("2026-10-06T09:30[Europe/Paris]").step.is_some());
1869        // The frames end with tomorrow: Wednesday is not given.
1870        let last = made.card.frames.last().unwrap();
1871        assert_eq!(last.until, at("2026-10-07T00:00[Europe/Paris]").timestamp().as_millisecond());
1872        // In French, with its typography.
1873        let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "fr", &[]);
1874        assert_eq!(fr.at("2026-10-06T05:00[Europe/Paris]").status, "Sommeil\u{202f}: rien ne dérange jusqu’à 07:00.");
1875        assert_eq!(fr.at("2026-10-05T23:30[Europe/Paris]").status, "Sommeil\u{202f}: rien ne dérange jusqu’à demain à 07:00.");
1876        assert_eq!(fr.at("2026-10-05T10:00[Europe/Paris]").status, "Travail jusqu’à 12:10.");
1877    }
1878
1879    #[test]
1880    fn a_pause_says_its_line_and_nothing_else() {
1881        let paused = Overrides { paused_since: Some(at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1882        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)]);
1883        let frame = made.at("2026-10-05T10:00[Europe/Paris]");
1884        assert_eq!((frame.kind.as_str(), frame.status.as_str()), ("paused", "Paused."));
1885        assert!(frame.porch.is_none() && frame.step.is_none() && !frame.codes && !frame.doses && !frame.events);
1886        // The pause lasts until you come back: one frame per day, all paused.
1887        assert!(made.card.frames.iter().all(|f| f.kind == "paused"));
1888        // Free time: its line; codes and doses reach you, as it says; no Porch, no step; the agenda stays.
1889        let free = Overrides { free_since: Some(at("2026-10-05T14:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1890        let made = make("2026-10-05T14:30[Europe/Paris]", &free, true, "en", &[]);
1891        let frame = made.at("2026-10-05T14:30[Europe/Paris]");
1892        assert_eq!(frame.kind, "free-time");
1893        assert_eq!(frame.status, "Free time: only your safe senders, doses and codes reach you. Work comes back when you do.");
1894        assert!(frame.porch.is_none() && frame.step.is_none() && frame.codes && frame.doses && frame.events);
1895        // It ends at the night's start: winding down then.
1896        assert_eq!(made.at("2026-10-05T22:30[Europe/Paris]").kind, "sleep");
1897    }
1898
1899    #[test]
1900    fn the_porch_keeps_its_hours() {
1901        // After a pause the Porch rests until its next hours: no mail named, when it opens.
1902        let rests = Overrides { porch_rests_until: Some(at("2026-10-06T09:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1903        let made = make("2026-10-05T15:00[Europe/Paris]", &rests, true, "en", &[]);
1904        let frame = made.at("2026-10-05T15:00[Europe/Paris]");
1905        let porch = frame.porch.as_ref().unwrap();
1906        assert_eq!(porch.line, "After the pause, the Porch opens tomorrow at 09:00.");
1907        assert!(porch.mail.is_empty());
1908        // The next morning, open again: the letters listed.
1909        assert_eq!(made.at("2026-10-06T09:30[Europe/Paris]").porch.as_ref().unwrap().mail.len(), 4);
1910        // Work now outside the hours: work's time, but the Porch keeps its own (closed until 09:00).
1911        let work_now = Overrides { work_now: Some(at("2026-10-05T20:30[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1912        let made = make("2026-10-05T17:30[Europe/Paris]", &work_now, true, "en", &[]);
1913        let frame = made.at("2026-10-05T17:30[Europe/Paris]");
1914        assert_eq!(frame.status, "Work shown until 20:30, by your choice.");
1915        let porch = frame.porch.as_ref().unwrap();
1916        assert_eq!(porch.line, "The Porch opens tomorrow at 09:00.");
1917        assert!(porch.mail.is_empty(), "never mail waiting for later");
1918        // Dinner comes first, then work again until 20:30.
1919        assert_eq!(made.at("2026-10-05T19:30[Europe/Paris]").kind, "meals");
1920        assert_eq!(made.at("2026-10-05T20:15[Europe/Paris]").status, "Work shown until 20:30, by your choice.");
1921        // Strangers' mail waits for work and admin time: in leisure the Porch names none of it.
1922        let config = config();
1923        let tr = Translator::new("en");
1924        let now = at("2026-10-05T17:30[Europe/Paris]");
1925        let blocks = blocks(&now);
1926        let overrides = Overrides::default();
1927        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
1928        let mut input = porch_input(&now);
1929        input.items = vec![message("A stranger <someone@elsewhere.example>", "Offer", Lane::Screener)];
1930        let card = snapshot(&moment, Some(&input), None, None, &[]);
1931        let evening = card.frames.iter().find(|f| f.kind == "leisure").unwrap();
1932        assert_eq!(evening.porch.as_ref().unwrap().line, "Nothing waits on the Porch.");
1933        assert!(evening.porch.as_ref().unwrap().mail.is_empty());
1934        let work = card.frames.iter().find(|f| f.kind == "work").unwrap();
1935        assert_eq!(card.mail[work.porch.as_ref().unwrap().mail[0]].sender, "A stranger");
1936    }
1937
1938    #[test]
1939    fn mail_is_listed_as_a_mail_card_lists_it() {
1940        let now = at("2026-10-05T10:00[Europe/Paris]");
1941        let stamp = |text: &str| at(text).timestamp().as_second();
1942        let mut config = config();
1943        config.accounts.push(toml::from_str::<Config>("[[account]]\nid = \"club\"\naddress = \"me@club.example\"\n").unwrap().accounts.remove(0));
1944        let tr = Translator::new("en");
1945        let blocks = blocks(&now);
1946        let overrides = Overrides::default();
1947        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
1948        let mut input = porch_input(&now);
1949        let mut club = sent_at("Ana <ana@example.org>", "Rehearsal moved", stamp("2026-10-05T09:12[Europe/Paris]"), false);
1950        club.card.account = Some("club".into());
1951        // Not shown on the card, even when the Porch would unfold them: set aside, waiting for your word on spam, hostile.
1952        let mut forged = sent_at("Bank <bank@bank.example>", "Your account", stamp("2026-10-05T09:50[Europe/Paris]"), false);
1953        forged.lane = Lane::SetAside;
1954        forged.priority = Priority::Above;
1955        let mut doubtful = sent_at("Offers <deals@shop.example>", "Big sale", stamp("2026-10-05T09:55[Europe/Paris]"), false);
1956        doubtful.lane = Lane::Review;
1957        input.items = vec![
1958            sent_at("Marie Dupont <marie@example.org>", "Dinner on Saturday", stamp("2026-10-04T18:30[Europe/Paris]"), true),
1959            club,
1960            sent_at("Paul <paul@example.org>", "The photos", stamp("2026-09-28T08:00[Europe/Paris]"), true),
1961            sent_at("Léa <lea@example.org>", "", stamp("2025-12-20T08:00[Europe/Paris]"), false),
1962            forged,
1963            doubtful,
1964        ];
1965        input.senders = Senders { safe: sioul_core::porch::SenderList::parse("marie@example.org\npaul@example.org\nlea@example.org\nana@example.org\n"), ..Senders::default() };
1966        let card = snapshot(&moment, Some(&input), None, None, &[]);
1967        let frame = &card.frames[0];
1968        let listed: Vec<&Mail> = frame.porch.as_ref().unwrap().mail.iter().map(|at| &card.mail[*at]).collect();
1969        // The newest first; the set-aside and the review queue's left out.
1970        assert_eq!(listed.iter().map(|m| m.sender.as_str()).collect::<Vec<_>>(), ["Ana", "Marie Dupont", "Paul", "Léa"]);
1971        // Its time today, until midnight; then "Yesterday"; then its day.
1972        let midnight = at("2026-10-06T00:00[Europe/Paris]").timestamp().as_millisecond();
1973        let next = at("2026-10-07T00:00[Europe/Paris]").timestamp().as_millisecond();
1974        let when = |m: &Mail| m.when.iter().map(|l| (l.until, l.text.clone())).collect::<Vec<_>>();
1975        assert_eq!(when(listed[0]), [(midnight, "09:12".to_string()), (next, "Yesterday".to_string()), (0, "5 Oct".to_string())]);
1976        assert_eq!(when(listed[1]), [(midnight, "Yesterday".to_string()), (0, "4 Oct".to_string())]);
1977        assert_eq!(when(listed[2]), [(0, "28 Sep".to_string())]);
1978        // Another year's: with its year.
1979        assert_eq!(when(listed[3]), [(0, "20 Dec 2025".to_string())]);
1980        // Not read yet (new/, or no S): bold in Java; read: not.
1981        assert_eq!(listed.iter().map(|m| m.unread).collect::<Vec<_>>(), [true, false, false, true]);
1982        // The account's place among yours: its colour in Java.
1983        assert_eq!((listed[0].account, listed[1].account), (1, 0));
1984        // No subject: said so.
1985        assert_eq!(listed[3].subject, "(no subject)");
1986        // Its file, to open it from the card.
1987        assert!(listed[0].key.ends_with(".sioul") && listed[0].key.contains("/new/"));
1988        // In French: "hier", "4 oct.", "1er".
1989        let fr = Translator::new("fr");
1990        let first = sent_at("Ana <ana@example.org>", "Hello", stamp("2026-10-01T09:00[Europe/Paris]"), false);
1991        assert_eq!(mail_when(&fr, first.card.date, &now).last().unwrap().text, "1er oct.");
1992        assert_eq!(mail_when(&fr, Some(stamp("2026-10-04T09:00[Europe/Paris]")), &now)[0].text, "hier");
1993        // At most twenty, the newest.
1994        let mut many = porch_input(&now);
1995        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();
1996        many.senders = Senders { safe: sioul_core::porch::SenderList::parse("*@example.org\n"), ..Senders::default() };
1997        let card = snapshot(&moment, Some(&many), None, None, &[]);
1998        let shown = &card.frames[0].porch.as_ref().unwrap().mail;
1999        assert_eq!(shown.len(), 20);
2000        assert_eq!(card.mail[shown[0]].subject, "Note 0");
2001        // Each message written once, whichever frames list it.
2002        assert_eq!(card.mail.len(), 20);
2003    }
2004
2005    #[test]
2006    fn details_off_names_no_one() {
2007        let doses = [dose("Magnesium · 300 mg", "09:45", "2026-10-05T09:45[Europe/Paris]", i64::MAX / 2000)];
2008        let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), false, "en", &doses);
2009        assert!(!made.card.details);
2010        let frame = made.at("2026-10-05T10:00[Europe/Paris]");
2011        assert_eq!(frame.status, "Work until 12:10.");
2012        // That mail waits, no one named, no count.
2013        let porch = frame.porch.as_ref().unwrap();
2014        assert_eq!(porch.line, "Mail waits on the Porch.");
2015        assert!(porch.mail.is_empty() && made.card.mail.is_empty());
2016        // The step without its title, nor a reason naming another step: its date.
2017        let step = frame.step.as_ref().unwrap();
2018        assert_eq!((step.title.as_str(), step.why.as_str()), ("Your next step", "By 30 October: three weeks left"));
2019        // A code, without its site or the code itself.
2020        assert_eq!(made.card.codes.len(), 1);
2021        assert_eq!(made.card.codes[0].line, "A code waits on the Porch.");
2022        // No dose line: a medicine's name says health.
2023        assert!(made.card.doses.is_empty());
2024        // The appointments at their times, none named.
2025        let agenda = made.card.agenda.as_ref().unwrap();
2026        assert!(agenda.events.iter().all(|e| e.title == "An event"));
2027        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");
2028        let all = serde_json::to_string(&made.card).unwrap();
2029        for private in ["Marie", "Dinner", "482913", "La Banque", "CAF", "Magnesium", "Dentist", "Choir", "party", "Trip"] {
2030            assert!(!all.contains(private), "{private} shown with the details off: {all}");
2031        }
2032        let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), false, "fr", &[]);
2033        assert_eq!(fr.card.codes[0].line, "Un code attend sur le Porche.");
2034        assert_eq!(fr.at("2026-10-05T10:00[Europe/Paris]").porch.as_ref().unwrap().line, "Des courriels attendent sur le Porche.");
2035        assert!(fr.card.agenda.as_ref().unwrap().events.iter().all(|e| e.title == "Un événement"));
2036    }
2037
2038    #[test]
2039    fn the_agenda_comes_day_by_day() {
2040        let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "en", &[]);
2041        let ms = |text: &str| at(text).timestamp().as_millisecond();
2042        let agenda = made.card.agenda.as_ref().unwrap();
2043        // Nearest first; the cancelled call left out.
2044        let titles: Vec<&str> = agenda.events.iter().map(|e| e.title.as_str()).collect();
2045        assert_eq!(titles, ["Choir", "Dentist", "Neighbours' party", "Trip to the sea", "Night train"]);
2046        let event = |title: &str| agenda.events.iter().find(|e| e.title == title).unwrap();
2047        // A time range; another day's end with its day; a whole day, its title alone; several, until when.
2048        assert_eq!(event("Dentist").time, "11:00 – 12:00");
2049        assert_eq!(event("Night train").time, "22:30 – Wed 21 Oct 06:45");
2050        assert_eq!((event("Neighbours' party").time.as_str(), event("Neighbours' party").all_day), ("", true));
2051        assert_eq!(event("Trip to the sea").time, "Until Fri 16 Oct");
2052        // Its calendar's colour, as "#rrggbb" whatever was written; none: Sioul's own.
2053        assert_eq!((event("Dentist").color.as_str(), event("Choir").color.as_str(), event("Neighbours' party").color.as_str()), ("#4c6b5c", "", "#b07d3f"));
2054        assert_eq!((event("Dentist").start, event("Dentist").end), (ms("2026-10-06T11:00[Europe/Paris]"), ms("2026-10-06T12:00[Europe/Paris]")));
2055        assert_eq!(event("Dentist").key, "/calendars/me/personal/event-0.ics");
2056        // Every day from today to a month on, each named as seen from the day before and on the day.
2057        assert_eq!(agenda.days.len(), 31);
2058        let names = |day: &Day| day.label.iter().map(|l| (l.until, l.text.clone())).collect::<Vec<_>>();
2059        assert_eq!(agenda.days[0].from, ms("2026-10-05T00:00[Europe/Paris]"));
2060        assert_eq!(names(&agenda.days[0]), [(ms("2026-10-06T00:00[Europe/Paris]"), "Today, Mon 5 Oct".to_string())]);
2061        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())]);
2062        assert_eq!(
2063            names(&agenda.days[7]),
2064            [(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())]
2065        );
2066        // The change of hour on 25 October: that day lasts 25 hours.
2067        let long = agenda.days.iter().find(|d| d.from == ms("2026-10-25T00:00[Europe/Paris]")).unwrap();
2068        assert_eq!(long.until - long.from, 25 * 3_600_000);
2069        assert_eq!(agenda.empty, "Nothing in your calendars for the coming month.");
2070        // Shown at every time but in a pause.
2071        assert!(made.card.frames.iter().all(|f| f.events));
2072        // An event over is left out; one running stays.
2073        let later = make("2026-10-05T19:30[Europe/Paris]", &Overrides::default(), true, "en", &[]);
2074        assert_eq!(later.card.agenda.as_ref().unwrap().events[0].title, "Choir");
2075        let after = make("2026-10-05T21:00[Europe/Paris]", &Overrides::default(), true, "en", &[]);
2076        assert_eq!(after.card.agenda.as_ref().unwrap().events[0].title, "Dentist");
2077        // Beyond a month: not listed.
2078        let config = config();
2079        let tr = Translator::new("en");
2080        let now = at("2026-10-05T10:00[Europe/Paris]");
2081        let blocks = blocks(&now);
2082        let overrides = Overrides::default();
2083        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2084        let far = events_of(&[("Far away", "2026-11-20T10:00[Europe/Paris]", "2026-11-20T11:00[Europe/Paris]", false, None, false)]);
2085        assert!(snapshot(&moment, None, None, Some(&far), &[]).agenda.unwrap().events.is_empty());
2086        // No calendar at all: no agenda on the card.
2087        let none = AgendaInput { events: Vec::new(), calendars: false, read_on: now.date(), aside: BTreeSet::new() };
2088        assert!(snapshot(&moment, None, None, Some(&none), &[]).agenda.is_none());
2089        // In French, as the phone's agenda says it.
2090        let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "fr", &[]);
2091        let agenda = fr.card.agenda.as_ref().unwrap();
2092        assert_eq!(names(&agenda.days[0])[0].1, "Aujourd’hui, lun. 5 oct.");
2093        assert_eq!(names(&agenda.days[3])[1].1, "Demain, jeu. 8 oct.");
2094        assert_eq!(names(&agenda.days[7])[0].1, "lun. 12 oct.");
2095        assert_eq!(agenda.events.iter().find(|e| e.title == "Trip to the sea").unwrap().time, "Jusqu’au ven. 16 oct.");
2096        assert_eq!(agenda.events.iter().find(|e| e.title == "Night train").unwrap().time, "22:30 – mer. 21 oct. 06:45");
2097        assert_eq!(fr.card.words.mail, "Courriels");
2098    }
2099
2100    #[test]
2101    fn no_count_at_all() {
2102        let now = at("2026-10-05T10:00[Europe/Paris]");
2103        let config = config();
2104        let overrides = Overrides::default();
2105        let blocks = blocks(&now);
2106        // A number anywhere but in a time (14:05) or a date: what a badge would be.
2107        let digits = regex_free_digits;
2108        for language in ["en", "fr"] {
2109            for n in [0, 1, 7, 12, 30] {
2110                for details in [true, false] {
2111                    let tr = Translator::new(language);
2112                    let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details, dnd: None };
2113                    let mut input = porch_input(&now);
2114                    input.items = (0..n).map(|i| message(&format!("Someone <s{i}@example.org>"), "Hello", Lane::People)).collect();
2115                    input.senders = Senders { safe: sioul_core::porch::SenderList::parse("*@example.org\n"), ..Senders::default() };
2116                    let card = snapshot(&moment, Some(&input), Some(&plan_input(&now)), Some(&some_events()), &[]);
2117                    for frame in &card.frames {
2118                        let shown = words(&card, frame);
2119                        assert!(!digits(&shown), "a number on the card: {shown}");
2120                        // Never how many came, in words either.
2121                        for count in ["e-mails came", "e-mail came", "courriels sont arrivés", "courriel est arrivé", "Many", "Beaucoup", "Seven", "Sept", "Twelve", "Douze"] {
2122                            assert!(!shown.contains(count), "a count on the card: {shown}");
2123                        }
2124                    }
2125                }
2126            }
2127        }
2128    }
2129
2130    /// Whether `text` holds a digit outside a time ("14:05") or a date ("30 October", "5 Oct", "1er oct.").
2131    fn regex_free_digits(text: &str) -> bool {
2132        let mut rest = String::new();
2133        let chars: Vec<char> = text.chars().collect();
2134        let mut i = 0;
2135        while i < chars.len() {
2136            // A time: two digits, ':', two digits.
2137            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) {
2138                i += 5;
2139                continue;
2140            }
2141            rest.push(chars[i]);
2142            i += 1;
2143        }
2144        // A day of the month before its name, long or short: "30 October", "30 octobre", "5 Oct", "1er oct.".
2145        let months = [
2146            "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",
2147            "Apr", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "janv.", "févr.", "avr.", "juil.", "sept.", "oct.", "nov.", "déc.",
2148        ];
2149        let mut cleaned = rest.clone();
2150        for month in months {
2151            for day in (1..=31).rev() {
2152                cleaned = cleaned.replace(&format!("{day} {month}"), month).replace(&format!("{day}\u{a0}{month}"), month).replace(&format!("{day}er {month}"), month);
2153            }
2154        }
2155        cleaned.chars().any(|c| c.is_ascii_digit())
2156    }
2157
2158    fn dose(name: &str, time: &str, when: &str, known_until: i64) -> Dose {
2159        let due = at(when).timestamp().as_second();
2160        Dose { name: name.into(), time: time.into(), at: due, until: due + 12 * 3600, known_until }
2161    }
2162
2163    #[test]
2164    fn doses_say_what_is_known_and_no_more() {
2165        let due = at("2026-10-05T09:45[Europe/Paris]").timestamp().as_second();
2166        // Known not marked until 10:05 (another device heard at 10:00, five minutes' freshness).
2167        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)];
2168        let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "en", &doses);
2169        let lines = &made.card.doses;
2170        assert_eq!(lines[0].line, "Magnesium · 300 mg, 09:45");
2171        assert_eq!(lines[0].check, "Magnesium · 300 mg, 09:45: check before taking it.");
2172        assert_eq!((lines[0].from, lines[0].known_until), (due * 1000, (due + 20 * 60) * 1000));
2173        // Not known from its time on: the check, at once (Java shows `check` when known_until is 0).
2174        assert_eq!(lines[1].known_until, 0);
2175        for line in lines {
2176            for word in [&line.line, &line.check] {
2177                let lower = word.to_lowercase();
2178                for never in ["missed", "late", "not taken", "overdue", "forgot"] {
2179                    assert!(!lower.contains(never), "{word}");
2180                }
2181            }
2182        }
2183        // Asleep: the dose shows (doses remind during sleep), not in a pause.
2184        assert!(made.at("2026-10-06T05:00[Europe/Paris]").doses);
2185        // Doses silent in sleep, as an older Sioul said it ([reminders] doses_in_sleep, read once to seed the matrix).
2186        let mut silent = config();
2187        silent.reminders.doses_in_sleep = false;
2188        let now = at("2026-10-05T10:00[Europe/Paris]");
2189        let tr = Translator::new("en");
2190        let blocks = blocks(&now);
2191        let overrides = Overrides::default();
2192        let moment = Moment { now: &now, config: &silent, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2193        let card = snapshot(&moment, None, None, None, &doses);
2194        let night = card.frames.iter().find(|f| f.kind == "sleep").unwrap();
2195        assert!(!night.doses, "doses during sleep: stay silent");
2196        assert!(night.codes, "codes you asked for come at once while you sleep (Q6; before, they waited on the Porch)");
2197        // As the matrix of what reaches you says (Settings ▸ What reaches you): doses held in sleep, codes on the Porch only then.
2198        let mut grid = config();
2199        grid.attention = toml::from_str::<Config>("[attention]\ndoses = [\"sleep:later\"]\ncodes = [\"sleep:never\"]\n").unwrap().attention;
2200        let card = snapshot(&Moment { config: &grid, ..moment }, None, None, None, &doses);
2201        let night = card.frames.iter().find(|f| f.kind == "sleep").unwrap();
2202        assert!(!night.doses && !night.codes);
2203        // In French, with its typography.
2204        let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "fr", &doses);
2205        assert_eq!(fr.card.doses[0].check, "Magnesium · 300 mg, 09:45\u{202f}: vérifiez avant de la prendre.");
2206    }
2207
2208    #[test]
2209    fn its_age_is_said_once_it_is_old() {
2210        let made = make("2026-10-05T14:05[Europe/Paris]", &Overrides::default(), true, "en", &[]);
2211        let card = &made.card;
2212        assert_eq!(card.stale_after, card.made + 15 * 60 * 1000);
2213        assert_eq!(card.stale[0].text, "at 14:05");
2214        assert_eq!(card.stale[0].until, at("2026-10-06T00:00[Europe/Paris]").timestamp().as_millisecond());
2215        assert_eq!(card.stale[1].text, "yesterday at 14:05");
2216        assert_eq!(card.stale[1].until, at("2026-10-07T00:00[Europe/Paris]").timestamp().as_millisecond());
2217        assert_eq!((card.stale[2].until, card.stale[2].text.as_str()), (0, "Mon 5 Oct 14:05"));
2218        assert_eq!(card.beyond, "Open Sioul to bring this card up to date.");
2219        let fr = make("2026-10-05T14:05[Europe/Paris]", &Overrides::default(), true, "fr", &[]);
2220        assert_eq!((fr.card.stale[0].text.as_str(), fr.card.stale[1].text.as_str()), ("à 14:05", "hier à 14:05"));
2221        // Written again a minute later, nothing new: said the same, Android need not draw it again.
2222        let config = config();
2223        let tr = Translator::new("en");
2224        let overrides = Overrides::default();
2225        let first = at("2026-10-05T14:05[Europe/Paris]");
2226        let (porch, plan) = (porch_input(&first), plan_input(&first));
2227        let card_at = |now: &Zoned| {
2228            let blocks = blocks(now);
2229            let moment = Moment { now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2230            snapshot(&moment, Some(&porch), Some(&plan), Some(&some_events()), &[])
2231        };
2232        let (before, after) = (card_at(&first), card_at(&at("2026-10-05T14:06[Europe/Paris]")));
2233        assert_ne!(before.made, after.made);
2234        assert_eq!(content(&before), content(&after));
2235        // A new letter: said differently.
2236        let mut more = porch_input(&first);
2237        more.items.push(message("Ana <ana@example.org>", "Tea?", Lane::People));
2238        let blocks = blocks(&first);
2239        let moment = Moment { now: &first, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2240        assert_ne!(content(&before), content(&snapshot(&moment, Some(&more), Some(&plan), Some(&some_events()), &[])));
2241    }
2242
2243    #[test]
2244    fn not_set_up_yet() {
2245        let now = at("2026-10-05T10:00[Europe/Paris]");
2246        let config = Config { accounts: Vec::new(), ..config() };
2247        let tr = Translator::new("en");
2248        let blocks = blocks(&now);
2249        let overrides = Overrides::default();
2250        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2251        let card = snapshot(&moment, Some(&porch_input(&now)), None, None, &[]);
2252        assert_eq!(card.frames[0].porch.as_ref().unwrap().line, "Sioul is not set up yet.");
2253        // Asleep it says only the night's line, set up or not.
2254        assert!(card.frames.iter().filter(|f| f.kind == "sleep").all(|f| f.porch.is_none()));
2255    }
2256
2257    /// The names HomeCard.java and HomeCardRows.java read, each where they
2258    /// read it: a name changed here and not there would leave the card blank.
2259    /// With SIOUL_CARD_SAMPLE set, the card is written there too (in French
2260    /// with SIOUL_CARD_SAMPLE_FR), to check the Java's rows on a computer:
2261    /// Thursday 8 October 2026 at 09:20, with the weather, reminders, a call,
2262    /// two events at once and a line on where you stopped.
2263    #[test]
2264    fn the_file_java_reads() {
2265        let doses = [dose("Magnesium · 300 mg", "09:45", "2026-10-08T09:45[Europe/Paris]", 0)];
2266        let made = full("2026-10-08T09:20[Europe/Paris]", true, "en", &doses);
2267        let json = serde_json::to_value(&made).unwrap();
2268        for (variable, language) in [("SIOUL_CARD_SAMPLE", "en"), ("SIOUL_CARD_SAMPLE_FR", "fr")] {
2269            if let Some(path) = std::env::var_os(variable) {
2270                let card = full("2026-10-08T09:20[Europe/Paris]", true, language, &doses);
2271                std::fs::write(path, serde_json::to_string_pretty(&card).unwrap()).unwrap();
2272            }
2273        }
2274        for key in ["made", "stale_after"] {
2275            assert!(json[key].is_i64(), "{key}");
2276        }
2277        assert!(json["details"].is_boolean() && json["beyond"].is_string());
2278        assert!(json["stale"][0]["until"].is_i64() && json["stale"][0]["text"].is_string());
2279        // Version 3: the frames under "times", none under "frames" (an older Java says `beyond`).
2280        assert!(json["frames"].is_null());
2281        let frame = &json["times"][0];
2282        for key in ["from", "until"] {
2283            assert!(frame[key].is_i64(), "{key}");
2284        }
2285        assert!(frame["status"].is_string() && frame["codes"].is_boolean() && frame["doses"].is_boolean() && frame["events"].is_boolean());
2286        assert!(frame["porch"]["line"].is_string() && frame["porch"]["mail"][0].is_u64());
2287        for key in ["title", "why", "uid"] {
2288            assert!(frame["step"][key].is_string(), "{key}");
2289        }
2290        assert!(frame["reminders"][0].is_u64() && frame["calls"][0].is_u64() && frame["overlaps"].is_boolean() && frame["stopped"].is_boolean() && frame["note"].is_string());
2291        assert!(json["v"] == 3 && json["words"]["mail"].is_string() && json["words"]["agenda"].is_string());
2292        // Today (HomeCard.java): the date's wordings, the weather hour by hour, the days, the credit.
2293        let today = &json["today"];
2294        assert!(today["date"][0]["until"].is_i64() && today["date"][0]["text"].is_string() && today["line"].is_string());
2295        let hour = &today["weather"]["hours"][0];
2296        assert!(hour["from"].is_i64() && hour["until"].is_i64());
2297        for slot in [&hour["now"], &hour["next"][0], &hour["parts"][0]] {
2298            for key in ["label", "icon", "temperature", "rain", "words"] {
2299                assert!(slot[key].is_string(), "{key}");
2300            }
2301        }
2302        let day = &today["weather"]["days"][0];
2303        assert!(day["from"].is_i64() && day["until"].is_i64());
2304        for key in ["label", "icon", "high", "low", "rain", "words"] {
2305            assert!(day[key].is_string(), "{key}");
2306        }
2307        assert!(today["weather"]["credit"].is_string());
2308        // What the Porch has (HomeCard.java).
2309        for key in ["line", "open"] {
2310            assert!(json["reminders"][0][key].is_string(), "{key}");
2311        }
2312        assert!(json["reminders"][0]["from"].is_i64() && json["reminders"][0]["until"].is_i64());
2313        assert!(json["calls"][0].is_string() && json["stopped"].is_string());
2314        assert!(json["overlaps"][0]["line"].is_string() && json["overlaps"][0]["from"].is_i64() && json["overlaps"][0]["until"].is_i64());
2315        // The messages (HomeCardRows.java).
2316        let mail = &json["mail"][0];
2317        for key in ["key", "sender", "subject", "text"] {
2318            assert!(mail[key].is_string(), "{key}");
2319        }
2320        assert!(mail["unread"].is_boolean() && mail["account"].is_i64());
2321        assert!(mail["when"][0]["until"].is_i64() && mail["when"][0]["text"].is_string());
2322        // The agenda (HomeCardRows.java).
2323        let agenda = &json["agenda"];
2324        assert!(agenda["empty"].is_string());
2325        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());
2326        let event = &agenda["events"][0];
2327        for key in ["key", "title", "time", "color"] {
2328            assert!(event[key].is_string(), "{key}");
2329        }
2330        assert!(event["all_day"].is_boolean() && event["start"].is_i64() && event["end"].is_i64());
2331        assert!(json["codes"][0]["line"].is_string() && json["codes"][0]["warning"].is_string() && json["codes"][0]["until"].is_i64());
2332        for key in ["from", "until", "known_until"] {
2333            assert!(json["doses"][0][key].is_i64(), "{key}");
2334        }
2335        assert!(json["doses"][0]["line"].is_string() && json["doses"][0]["check"].is_string());
2336        assert!(frame["dnd"].is_string());
2337        // Asleep: no Porch, no step, written as null (Java's optJSONObject).
2338        let night = serde_json::to_value(Made { card: made }.at("2026-10-08T23:30[Europe/Paris]")).unwrap();
2339        assert!(night["porch"].is_null() && night["step"].is_null());
2340    }
2341
2342    #[test]
2343    fn the_do_not_disturb_everywhere_says_its_line() {
2344        let now = at("2026-10-05T14:00[Europe/Paris]");
2345        let config = config();
2346        let tr = Translator::new("en");
2347        let blocks = blocks(&now);
2348        let overrides = Overrides::default();
2349        let until = at("2026-10-05T15:00[Europe/Paris]").timestamp().as_second();
2350        let line = "Do not disturb, on every device, until 15:00.";
2351        let moment = serde_json::json!({ "on": true, "why": "manual", "until": "15:00", "until_at": until, "line": line, "everywhere": true, "here": true });
2352        let dnd = Dnd::of(&moment).unwrap();
2353        let (porch, plan) = (porch_input(&now), plan_input(&now));
2354        let card_with = |dnd: Option<&Dnd>| {
2355            let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd };
2356            snapshot(&moment, Some(&porch), Some(&plan), None, &[])
2357        };
2358        let made = Made { card: card_with(Some(&dnd)) };
2359        // Under the status line until 15:00, where a frame ends; gone after.
2360        assert_eq!(made.at("2026-10-05T14:30[Europe/Paris]").dnd, line);
2361        assert_eq!(made.at("2026-10-05T14:30[Europe/Paris]").until, at("2026-10-05T15:00[Europe/Paris]").timestamp().as_millisecond());
2362        assert_eq!(made.at("2026-10-05T15:00[Europe/Paris]").dnd, "");
2363        // Off, or for a reason the status line says already (sleep, the pause, free time): nothing.
2364        for (on, why) in [(false, "manual"), (true, "sleep"), (true, "paused"), (true, "free-time")] {
2365            assert_eq!(Dnd::of(&serde_json::json!({ "on": on, "why": why, "line": line, "until_at": until })), None, "{on} {why}");
2366        }
2367        // A focus session's, its end no promise: the frame for now only.
2368        let focus = Dnd::of(&serde_json::json!({ "on": true, "why": "focus", "line": line, "until_at": 0 })).unwrap();
2369        assert_eq!(focus.until, None);
2370        // Until switched off: in every frame but the night's.
2371        let lasting = Dnd::of(&serde_json::json!({ "on": true, "why": "manual", "line": line, "until_at": 0 })).unwrap();
2372        let card = card_with(Some(&lasting));
2373        assert!(card.frames.iter().all(|f| (f.kind == "sleep") == f.dnd.is_empty()), "{:#?}", card.frames);
2374        // In a pause, its line alone.
2375        let paused = Overrides { paused_since: Some(now.timestamp().as_second() - 60), ..Overrides::default() };
2376        let moment = Moment { now: &now, config: &config, overrides: &paused, blocks: &blocks, tr: &tr, details: true, dnd: Some(&lasting) };
2377        assert!(snapshot(&moment, Some(&porch), Some(&plan), None, &[]).frames.iter().all(|f| f.dnd.is_empty()));
2378        // Its end not said: in the frame for now only.
2379        let unsaid = Dnd::of(&serde_json::json!({ "on": true, "why": "manual", "line": line })).unwrap();
2380        let card = card_with(Some(&unsaid));
2381        assert_eq!(card.frames[0].dnd, line);
2382        assert!(card.frames[1..].iter().all(|f| f.dnd.is_empty()));
2383        // Without the window (a receiver alone): the card on disk gets its lines, a frame cut at its end.
2384        let mut written = serde_json::to_value(card_with(None)).unwrap();
2385        let before = written["times"].as_array().unwrap().len();
2386        let stamp = now.timestamp().as_millisecond();
2387        assert!(patch_dnd(&mut written, Some(&dnd), stamp));
2388        let frames = written["times"].as_array().unwrap().clone();
2389        assert_eq!(frames.len(), before + 1, "cut at 15:00");
2390        let shown = |when: &str| {
2391            let t = at(when).timestamp().as_millisecond();
2392            frames.iter().find(|f| f["from"].as_i64().unwrap() <= t && t < f["until"].as_i64().unwrap()).unwrap()["dnd"].clone()
2393        };
2394        assert_eq!(shown("2026-10-05T14:59[Europe/Paris]"), line);
2395        assert_eq!(shown("2026-10-05T15:01[Europe/Paris]"), "");
2396        // Said again the same: nothing changes; switched off: the lines go.
2397        assert!(!patch_dnd(&mut written, Some(&dnd), stamp));
2398        assert!(patch_dnd(&mut written, None, stamp));
2399        assert!(written["times"].as_array().unwrap().iter().all(|f| f["dnd"] == ""));
2400        // A card an older Sioul wrote (version 2, its frames under "frames"): its lines said again all the same.
2401        let mut older = serde_json::json!({ "v": 2, "frames": [{ "from": 0, "until": i64::MAX, "kind": "work", "dnd": "" }] });
2402        assert!(patch_dnd(&mut older, Some(&lasting), stamp));
2403        assert_eq!(older["frames"][0]["dnd"], line);
2404    }
2405
2406    /// Open-Meteo's answer for Geneva, asked on Thursday 8 October 2026 at 09:29 (weather.rs's tests).
2407    const SAVED: &str = include_str!("../../sioul-core/tests/fixtures/weather/open-meteo.json");
2408
2409    /// The forecast kept, fetched a minute before `now`.
2410    fn forecast_at(now: &Zoned) -> Forecast {
2411        forecast::parse(SAVED, now.timestamp().as_second() - 60).unwrap()
2412    }
2413
2414    /// A reminder of `kind`, due at `at` until `until` (Paris times).
2415    fn reminder(kind: reminders::Kind, key: &str, at_: &str, until: &str, title: &str, body: &str, target: &str, work: bool) -> Reminder {
2416        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 }
2417    }
2418
2419    /// A date asked for a work task, a payment, a paper from tomorrow, and an
2420    /// event's reminder before it (the agenda card's and Android's: not said here).
2421    fn some_reminders() -> Vec<Reminder> {
2422        use reminders::Kind;
2423        vec![
2424            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),
2425            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),
2426            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),
2427            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),
2428        ]
2429    }
2430
2431    /// The calls declined as the Porch would list them: one, from the night
2432    /// before, its caller let through in work and leisure time, not asleep.
2433    fn some_calls(moment: &sioul_core::attention::Now, at_: &Zoned) -> Vec<String> {
2434        use sioul_core::attention::Column;
2435        if moment.is(Column::Sleep) || moment.is(Column::Pause) {
2436            return Vec::new();
2437        }
2438        let day = if at_.date() == Date::constant(2026, 10, 8) { "While you slept" } else { "Yesterday, while you slept" };
2439        vec![format!("{day}, Marie Dupont called at 07:40.")]
2440    }
2441
2442    /// The full card at `now` (a Thursday, 8 October 2026), as a phone shows it:
2443    /// the weather at a place chosen, the reminders, a call declined, two events
2444    /// at once at 11:00 and on Friday, a line left on where you stopped.
2445    fn full(now: &str, details: bool, language: &str, doses: &[Dose]) -> Snapshot {
2446        let now = at(now);
2447        let config = config();
2448        let tr = Translator::new(language);
2449        let blocks = blocks(&now);
2450        let overrides = Overrides::default();
2451        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details, dnd: None };
2452        let agenda = events_of(&[
2453            ("Dentist", "2026-10-08T11:00[Europe/Paris]", "2026-10-08T12:00[Europe/Paris]", false, None, false),
2454            ("School meeting", "2026-10-08T11:30[Europe/Paris]", "2026-10-08T12:30[Europe/Paris]", false, None, false),
2455            ("Choir", "2026-10-09T19:00[Europe/Paris]", "2026-10-09T20:30[Europe/Paris]", false, None, false),
2456            ("Neighbours' party", "2026-10-09T20:00[Europe/Paris]", "2026-10-09T23:00[Europe/Paris]", false, None, false),
2457            ("Trip to the sea", "2026-10-14T00:00[Europe/Paris]", "2026-10-17T00:00[Europe/Paris]", true, None, false),
2458        ]);
2459        let reminded = some_reminders();
2460        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()) };
2461        snapshot_with(&moment, Some(&porch_input(&now)), Some(&plan_input(&now)), Some(&agenda), doses, &more)
2462    }
2463
2464    #[test]
2465    fn today_comes_first_hour_by_hour() {
2466        let card = full("2026-10-08T09:20[Europe/Paris]", true, "en", &[]);
2467        let ms = |text: &str| at(text).timestamp().as_millisecond();
2468        // The date, then tomorrow's from midnight: the frames end with tomorrow.
2469        let dates: Vec<(i64, &str)> = card.today.date.iter().map(|l| (l.until, l.text.as_str())).collect();
2470        assert_eq!(dates, [(ms("2026-10-09T00:00[Europe/Paris]"), "Thursday 8 October"), (ms("2026-10-10T00:00[Europe/Paris]"), "Friday 9 October")]);
2471        assert_eq!(card.today.line, "");
2472        let weather = card.today.weather.as_ref().unwrap();
2473        assert_eq!(weather.credit, "Weather: Open-Meteo.com");
2474        // Hour by hour from the hour under way to the end of the last frame (Saturday's midnight).
2475        assert_eq!(weather.hours.first().unwrap().from, ms("2026-10-08T09:00[Europe/Paris]"));
2476        assert_eq!(weather.hours.last().unwrap().until, card.frames.last().unwrap().until);
2477        assert!(weather.hours.windows(2).all(|w| w[0].until == w[1].from), "no hole between the hours");
2478        // Each hour says its own now, its next four hours, its next parts of the day; every
2479        // hour of every frame has its four (the forecast holds three days).
2480        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<_>>());
2481        let hour = |text: &str| weather.hours.iter().find(|h| h.from <= ms(text) && ms(text) < h.until).unwrap();
2482        let morning = hour("2026-10-08T09:20[Europe/Paris]");
2483        assert_eq!((morning.now.temperature.as_str(), morning.now.rain.as_str()), ("16°", "98 %"));
2484        assert_eq!(morning.next.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["10:00", "11:00", "12:00", "13:00"]);
2485        assert_eq!(morning.parts.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["Afternoon", "Evening", "Night", "Morning"]);
2486        let afternoon = hour("2026-10-08T14:30[Europe/Paris]");
2487        assert_eq!((afternoon.now.temperature.as_str(), afternoon.next[0].label.as_str()), ("14°", "15:00"));
2488        // The afternoon's last hours are among the four: the evening comes first.
2489        assert_eq!(afternoon.parts.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["Evening", "Night", "Morning", "Afternoon"]);
2490        let late = hour("2026-10-09T22:10[Europe/Paris]");
2491        assert_eq!(late.next.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["23:00", "00:00", "01:00", "02:00"]);
2492        // The days, each from its midnight to the next: Java shows the seven after today.
2493        assert_eq!(weather.days.len(), 9);
2494        assert_eq!((weather.days[1].from, weather.days[1].until), (ms("2026-10-09T00:00[Europe/Paris]"), ms("2026-10-10T00:00[Europe/Paris]")));
2495        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°"));
2496        // In French, the heading with its capital.
2497        let fr = full("2026-10-08T09:20[Europe/Paris]", true, "fr", &[]);
2498        assert_eq!(fr.today.date[0].text, "Jeudi 8 octobre");
2499        assert_eq!(fr.today.weather.as_ref().unwrap().credit, "Météo\u{202f}: Open-Meteo.com");
2500        // Details off: the weather is no detail, said all the same.
2501        let plain = full("2026-10-08T09:20[Europe/Paris]", false, "en", &[]);
2502        assert_eq!(plain.today, card.today);
2503    }
2504
2505    #[test]
2506    fn no_place_no_weather() {
2507        let now = at("2026-10-08T09:20[Europe/Paris]");
2508        let config = config();
2509        let tr = Translator::new("en");
2510        let blocks = blocks(&now);
2511        let overrides = Overrides::default();
2512        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2513        let with = |weather: WeatherInput| snapshot_with(&moment, None, None, None, &[], &More { weather, ..More::default() }).today;
2514        // No place chosen: no weather part, one quiet line saying where to choose it.
2515        let none = with(WeatherInput::default());
2516        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."));
2517        assert_eq!(none.date[0].text, "Thursday 8 October");
2518        // A place, but no forecast yet, or one kept from two days ago: none yet.
2519        for forecast in [None, Some(forecast::parse(SAVED, now.timestamp().as_second() - 2 * 86_400).unwrap())] {
2520            let shown = with(WeatherInput { place: true, forecast });
2521            assert_eq!((shown.weather.is_none(), shown.line.as_str()), (true, "No forecast yet."));
2522        }
2523        // In French, with its typography.
2524        let fr = Translator::new("fr");
2525        let moment = Moment { tr: &fr, ..moment };
2526        let said = snapshot_with(&moment, None, None, None, &[], &More::default()).today.line;
2527        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.");
2528    }
2529
2530    #[test]
2531    fn the_porch_lines_but_no_mail() {
2532        let card = full("2026-10-08T09:20[Europe/Paris]", true, "en", &[dose("Magnesium · 300 mg", "09:45", "2026-10-08T09:45[Europe/Paris]", 0)]);
2533        let made = Made { card };
2534        let card = &made.card;
2535        // The reminders of dates, waits, payments and papers, soonest first; never an event's (Android tells those).
2536        let lines: Vec<&str> = card.reminders.iter().map(|r| r.line.as_str()).collect();
2537        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"]);
2538        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()));
2539        // At work, all three (Java shows each from its time); in the evening, work's
2540        // date waits for work; asleep, none; their row says so.
2541        assert_eq!(made.at("2026-10-08T10:00[Europe/Paris]").reminders, [0, 1, 2]);
2542        assert_eq!(made.at("2026-10-08T17:30[Europe/Paris]").reminders, [1, 2]);
2543        assert!(made.at("2026-10-08T23:30[Europe/Paris]").reminders.is_empty());
2544        // The call declined, as the Porch lists it, said from each day.
2545        let work = made.at("2026-10-08T10:00[Europe/Paris]");
2546        assert_eq!(card.calls[work.calls[0]], "While you slept, Marie Dupont called at 07:40.");
2547        assert_eq!(card.calls[made.at("2026-10-09T10:00[Europe/Paris]").calls[0]], "Yesterday, while you slept, Marie Dupont called at 07:40.");
2548        assert!(made.at("2026-10-08T23:30[Europe/Paris]").calls.is_empty());
2549        // Two events at once: today's, then Friday's on Friday; never asleep.
2550        let overlaps: Vec<(&str, i64)> = card.overlaps.iter().map(|o| (o.line.as_str(), o.from)).collect();
2551        assert_eq!(
2552            overlaps,
2553            [
2554                ("Today, two events at once: Dentist and School meeting.", at("2026-10-08T00:00[Europe/Paris]").timestamp().as_millisecond()),
2555                ("Today, two events at once: Choir and Neighbours' party.", at("2026-10-09T00:00[Europe/Paris]").timestamp().as_millisecond()),
2556            ]
2557        );
2558        assert_eq!(card.overlaps[0].until, at("2026-10-08T12:30[Europe/Paris]").timestamp().as_millisecond());
2559        assert!(work.overlaps && !made.at("2026-10-08T23:30[Europe/Paris]").overlaps);
2560        // Now: the step and where you stopped, in work time; not in the evening.
2561        assert_eq!(card.stopped, "Where you stopped: Halfway through the CAF's form, page 2");
2562        assert!(work.stopped && work.step.is_some());
2563        assert!(!made.at("2026-10-08T17:30[Europe/Paris]").stopped);
2564        // Nothing of the mail on the full card: its lines name no sender and no subject
2565        // (the mail card lists them, from the same file).
2566        for frame in &card.frames {
2567            let shown = full_card_words(card, frame);
2568            for mail in ["Marie Dupont <", "Dinner on Saturday", "The photos", "Paul", "Léa", "A question"] {
2569                assert!(!shown.contains(mail), "{mail} on the full card: {shown}");
2570            }
2571        }
2572        assert!(!card.mail.is_empty(), "the mail card's messages are still written");
2573        // Details off: none named.
2574        let plain = full("2026-10-08T09:20[Europe/Paris]", false, "en", &[]);
2575        assert!(plain.reminders.iter().all(|r| r.line == "A reminder waits in Sioul."));
2576        assert_eq!(plain.calls, ["A call was declined: it is on the Porch."]);
2577        assert_eq!(plain.overlaps[0].line, "Today, two events at once.");
2578        assert_eq!(plain.stopped, "Your line on where you stopped waits in Sioul.");
2579        let all = serde_json::to_string(&serde_json::json!([plain.reminders, plain.calls, plain.overlaps, plain.stopped])).unwrap();
2580        for private in ["CAF", "Rent", "Passport", "Marie", "Dentist", "Choir", "Halfway"] {
2581            assert!(!all.contains(private), "{private} shown with the details off: {all}");
2582        }
2583        // In French.
2584        let fr = full("2026-10-08T09:20[Europe/Paris]", true, "fr", &[]);
2585        assert_eq!(fr.overlaps[0].line, "Aujourd’hui, deux événements en même temps\u{202f}: Dentist et School meeting.");
2586        assert_eq!(fr.stopped, "Où vous en étiez\u{202f}: Halfway through the CAF's form, page 2");
2587        // In a pause: none of it.
2588        let now = at("2026-10-08T10:00[Europe/Paris]");
2589        let config = config();
2590        let tr = Translator::new("en");
2591        let blocks = blocks(&now);
2592        let paused = Overrides { paused_since: Some(now.timestamp().as_second() - 60), ..Overrides::default() };
2593        let moment = Moment { now: &now, config: &config, overrides: &paused, blocks: &blocks, tr: &tr, details: true, dnd: None };
2594        let reminded = some_reminders();
2595        let card = snapshot_with(&moment, None, None, None, &[], &More { reminders: &reminded, calls: Some(&some_calls), stopped: Some("Halfway".into()), ..More::default() });
2596        assert!(card.frames.iter().all(|f| f.reminders.is_empty() && f.calls.is_empty() && !f.overlaps && !f.stopped));
2597    }
2598
2599    /// Every word the full card can show in `frame` (HomeCard.java): today, the
2600    /// status line, the Porch's lines, Now.
2601    fn full_card_words(card: &Snapshot, frame: &Frame) -> String {
2602        let mut out: Vec<String> = card.today.date.iter().map(|l| l.text.clone()).collect();
2603        out.extend([card.today.line.clone(), frame.status.clone(), frame.dnd.clone(), frame.note.clone(), card.stopped.clone()]);
2604        out.extend(card.codes.iter().flat_map(|c| [c.line.clone(), c.warning.clone()]));
2605        out.extend(card.doses.iter().flat_map(|d| [d.line.clone(), d.check.clone()]));
2606        out.extend(frame.reminders.iter().map(|at| card.reminders[*at].line.clone()));
2607        out.extend(frame.calls.iter().map(|at| card.calls[*at].clone()));
2608        out.extend(card.overlaps.iter().map(|o| o.line.clone()));
2609        out.extend(frame.step.iter().map(|s| s.title.clone()));
2610        out.join("\n")
2611    }
2612
2613    #[test]
2614    fn a_reminders_tap_opens_what_it_is_about() {
2615        let now = 1_791_300_000_000;
2616        let none = String::new;
2617        let tap_on = |key: &str| tap(&format!(r#"{{"open":"reminder","key":"{key}","at":{now}}}"#), now);
2618        assert_eq!(tap_on("sioul:task/form"), Some(("task", "sioul:task/form".to_string(), "form".to_string())));
2619        assert_eq!(tap_on("sioul:budget/home"), Some(("budget", "sioul:budget/home".to_string(), none())));
2620        assert_eq!(tap_on("sioul:paper/passport"), Some(("paper", "sioul:paper/passport".to_string(), "passport".to_string())));
2621        assert_eq!(tap_on("sioul:contract/box"), Some(("contract", "sioul:contract/box".to_string(), "box".to_string())));
2622        assert_eq!(tap_on(""), Some(("porch", none(), none())));
2623    }
2624
2625    #[test]
2626    fn a_tap_is_taken_once_and_not_late() {
2627        let now = 1_791_300_000_000;
2628        let none = String::new;
2629        assert_eq!(tap(&format!(r#"{{"open":"porch","at":{now}}}"#), now + 1000), Some(("porch", none(), none())));
2630        assert_eq!(tap(&format!(r#"{{"open":"now","uid":"a b/c","at":{now}}}"#), now), Some(("now", "sioul:task/a%20b%2Fc".to_string(), none())));
2631        assert_eq!(tap(&format!(r#"{{"open":"now","uid":"","at":{now}}}"#), now), Some(("now", none(), none())));
2632        // A message, on the Porch in its Reader; an event, in the Agenda; by their files.
2633        let file = "/mail/me/INBOX/new/1759600000.a.sioul";
2634        assert_eq!(tap(&format!(r#"{{"open":"mail","key":"{file}","at":{now}}}"#), now), Some(("card-mail", none(), file.to_string())));
2635        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())));
2636        assert_eq!(tap(&format!(r#"{{"open":"agenda","at":{now}}}"#), now), Some(("agenda", none(), none())));
2637        // Without their files, their pages.
2638        assert_eq!(tap(&format!(r#"{{"open":"mail","at":{now}}}"#), now), Some(("porch", none(), none())));
2639        assert_eq!(tap(&format!(r#"{{"open":"event","key":"","at":{now}}}"#), now), Some(("agenda", none(), none())));
2640        // Older than five minutes: Sioul did not come up for it.
2641        assert_eq!(tap(&format!(r#"{{"open":"porch","at":{now}}}"#), now + 6 * 60 * 1000), None);
2642        assert_eq!(tap(r#"{"open":"elsewhere","at":0}"#, 0), None);
2643        assert_eq!(tap("not json", now), None);
2644    }
2645
2646    /// New mail between two drawings of the card: its messages and codes made
2647    /// again as a whole card would make them, frame by frame; the rest of the
2648    /// card, and the frames that say nothing of the Porch, left as they were.
2649    #[test]
2650    fn patch_mail_makes_the_porch_again_and_nothing_else() {
2651        let now = at("2026-10-05T10:00[Europe/Paris]");
2652        let config = config();
2653        let tr = Translator::new("en");
2654        let blocks = blocks(&now);
2655        let overrides = Overrides::default();
2656        let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2657        let (plan, agenda) = (plan_input(&now), some_events());
2658        let before = porch_input(&now);
2659        let written = serde_json::to_value(snapshot(&moment, Some(&before), Some(&plan), Some(&agenda), &[])).unwrap();
2660        // Since then: Tom's letter read and gone from the Porch, one from Paul come, the code used up.
2661        let mut after = porch_input(&now);
2662        after.items.retain(|m| m.card.from_address.as_deref() != Some("tom@example.org") && m.code.is_none());
2663        after.items.insert(0, sent_at("Paul <paul@example.org>", "Tickets for Sunday", now.timestamp().as_second() - 60, false));
2664        let mut card = written.clone();
2665        assert!(patch_mail(&mut card, &moment, &after));
2666        let whole = serde_json::to_value(snapshot(&moment, Some(&after), Some(&plan), Some(&agenda), &[])).unwrap();
2667        assert_eq!(card["mail"], whole["mail"]);
2668        assert_eq!(card["codes"], whole["codes"]);
2669        assert_eq!(card["codes"], serde_json::json!([]), "the code gone with its message");
2670        let (frames, made, was) = (card["times"].as_array().unwrap(), whole["times"].as_array().unwrap(), written["times"].as_array().unwrap());
2671        assert_eq!(frames.len(), made.len());
2672        for ((frame, made), was) in frames.iter().zip(made).zip(was) {
2673            assert_eq!(frame["porch"], made["porch"], "the frame from {}", frame["from"]);
2674            // Its other words as they were: its line, its step, its agenda.
2675            for key in ["from", "until", "status", "step", "note", "events", "codes", "doses"] {
2676                assert_eq!(frame[key], was[key], "{key}");
2677            }
2678            if was["porch"].is_null() {
2679                assert!(frame["porch"].is_null(), "asleep, no Porch made");
2680            }
2681        }
2682        let senders: Vec<&str> = card["mail"].as_array().unwrap().iter().map(|m| m["sender"].as_str().unwrap()).collect();
2683        assert!(senders.contains(&"Paul") && !senders.contains(&"Tom"), "{senders:?}");
2684        for key in ["today", "agenda", "words", "made", "stale_after", "details"] {
2685            assert_eq!(card[key], written[key], "{key}");
2686        }
2687        // Nothing new since: nothing changed, nothing to write.
2688        assert!(!patch_mail(&mut card, &moment, &after));
2689        // A card written by another version: left alone.
2690        let mut older = written.clone();
2691        older["v"] = serde_json::json!(2);
2692        let kept = older.clone();
2693        assert!(!patch_mail(&mut older, &moment, &after));
2694        assert_eq!(older, kept);
2695    }
2696}