1use 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
64const FILE: &str = "home-card.json";
67const VERSION: u32 = 3;
71const REMINDERS: usize = 12;
73const FORECAST_KEPT: i64 = 24 * 3600;
75const OPENED: &str = "home-card-opened";
77pub(crate) const PLAIN: &str = "home-card-plain";
79const FRESH_MS: i64 = 15 * 60 * 1000;
81const REWRITE_EVERY: i64 = 5 * 60;
83const MAIL: usize = 20;
85const HORIZON_DAYS: i64 = 30;
87const EVENTS: usize = 60;
89const TEXT_CHARS: usize = 160;
91const FRAMES: usize = 48;
93const TAP_KEPT_MS: i64 = 5 * 60 * 1000;
95
96#[derive(Debug, Clone, PartialEq, Serialize)]
98pub(crate) struct Snapshot {
99 v: u32,
100 made: i64,
102 details: bool,
104 stale_after: i64,
106 stale: Vec<Label>,
108 beyond: String,
110 words: Words,
112 today: TodayLines,
114 #[serde(rename = "times")]
116 frames: Vec<Frame>,
117 mail: Vec<Mail>,
119 agenda: Option<AgendaLines>,
121 codes: Vec<Code>,
123 doses: Vec<DoseLine>,
125 reminders: Vec<ReminderLine>,
129 calls: Vec<String>,
131 overlaps: Vec<Timed>,
134 stopped: String,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize)]
141struct TodayLines {
142 date: Vec<Label>,
144 line: String,
147 weather: Option<WeatherLines>,
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize)]
152struct WeatherLines {
153 hours: Vec<WeatherHour>,
155 days: Vec<WeatherDay>,
158 credit: String,
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize)]
164struct WeatherHour {
165 from: i64,
166 until: i64,
167 now: Slot,
169 next: Vec<Slot>,
171 parts: Vec<Slot>,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize)]
177struct WeatherDay {
178 from: i64,
179 until: i64,
180 #[serde(flatten)]
181 day: DaySlot,
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize)]
186struct ReminderLine {
187 line: String,
190 from: i64,
191 until: i64,
192 open: String,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize)]
198struct Timed {
199 line: String,
200 from: i64,
201 until: i64,
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize)]
206struct Words {
207 mail: String,
208 agenda: String,
209}
210
211#[derive(Debug, Clone, PartialEq, Serialize)]
213struct Frame {
214 from: i64,
215 until: i64,
216 kind: String,
218 status: String,
220 dnd: String,
223 porch: Option<PorchLines>,
225 step: Option<Step>,
227 codes: bool,
231 doses: bool,
233 events: bool,
235 reminders: Vec<usize>,
240 calls: Vec<usize>,
243 overlaps: bool,
245 stopped: bool,
247 note: String,
249}
250
251#[derive(Debug, Clone, PartialEq, Serialize)]
253struct PorchLines {
254 line: String,
258 mail: Vec<usize>,
260}
261
262#[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#[derive(Debug, Clone, PartialEq, Serialize)]
277struct Mail {
278 key: String,
280 sender: String,
282 subject: String,
283 text: String,
285 unread: bool,
287 account: i32,
290 when: Vec<Label>,
293}
294
295#[derive(Debug, Clone, PartialEq, Serialize)]
297struct AgendaLines {
298 empty: String,
300 days: Vec<Day>,
303 events: Vec<Event>,
305}
306
307#[derive(Debug, Clone, PartialEq, Serialize)]
309struct Day {
310 from: i64,
312 until: i64,
313 label: Vec<Label>,
315}
316
317#[derive(Debug, Clone, PartialEq, Serialize)]
319struct Event {
320 key: String,
322 title: String,
324 time: String,
326 all_day: bool,
327 start: i64,
329 end: i64,
330 color: String,
332}
333
334#[derive(Debug, Clone, PartialEq, Serialize)]
335struct Step {
336 title: String,
337 why: String,
339 uid: String,
340}
341
342#[derive(Debug, Clone, PartialEq, Serialize)]
343struct Code {
344 line: String,
346 warning: String,
348 until: i64,
349}
350
351#[derive(Debug, Clone, PartialEq, Serialize)]
352struct DoseLine {
353 line: String,
355 check: String,
357 from: i64,
358 until: i64,
359 known_until: i64,
361}
362
363#[derive(Debug, Clone, PartialEq, Serialize)]
364struct Label {
365 until: i64,
367 text: String,
368}
369
370#[derive(Debug, Clone, PartialEq)]
372pub(crate) struct Dose {
373 pub name: String,
375 pub time: String,
377 pub at: i64,
379 pub until: i64,
380 pub known_until: i64,
382}
383
384pub(crate) struct PorchInput {
386 items: Vec<Triaged>,
387 senders: Senders,
388 store: Option<ProjectStore>,
389}
390
391pub(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
400pub(crate) struct AgendaInput {
403 events: Vec<Occurrence>,
404 calendars: bool,
405 read_on: Date,
407 aside: BTreeSet<String>,
409}
410
411#[derive(Default)]
414pub(crate) struct WeatherInput {
415 place: bool,
416 forecast: Option<Forecast>,
417}
418
419type CallsAt<'a> = &'a dyn Fn(&sioul_core::attention::Now, &Zoned) -> Vec<String>;
422
423#[derive(Default)]
425struct More<'a> {
426 weather: WeatherInput,
427 reminders: &'a [Reminder],
429 calls: Option<CallsAt<'a>>,
430 stopped: Option<String>,
432}
433
434struct 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 dnd: Option<&'a Dnd>,
444}
445
446#[derive(Debug, Clone, PartialEq)]
450struct Dnd {
451 line: String,
453 until: Option<i64>,
456}
457
458impl Dnd {
459 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 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 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
494fn 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
503fn midnight_after(at: &Zoned) -> Option<Zoned> {
505 at.date().tomorrow().ok().and_then(|d| d.to_zoned(at.time_zone().clone()).ok())
506}
507
508fn 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
520fn 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
526fn 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
539fn 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
575fn 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 Kind::Asked => vec![&r.body, &r.title],
590 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
600fn overlap_lines(m: &Moment, input: &AgendaInput) -> Vec<Timed> {
604 let zone = m.now.time_zone().clone();
605 let now = m.now.timestamp().as_second();
606 let day_of = |at: i64| Timestamp::from_second(at).ok().map(|t| t.to_zoned(zone.clone()).date());
607 let tomorrow = m.now.date().tomorrow().ok();
608 sioul_core::overlaps::overlaps(&input.events)
609 .into_iter()
610 .filter(|o| !input.aside.contains(&o.key) && o.first.end.max(o.second.end) > now)
611 .filter_map(|o| {
612 let day = day_of(o.second.start)?;
613 if day != m.now.date() && Some(day) != tomorrow {
614 return None;
615 }
616 let from = day.to_zoned(zone.clone()).ok()?;
617 let title = |e: &Occurrence| if e.summary.trim().is_empty() { m.tr.text("agenda-untitled", None) } else { e.summary.trim().to_string() };
618 let line = if m.details { said(m.tr, "home-card-overlap", &[("first", title(&o.first)), ("second", title(&o.second))]) } else { m.tr.text("home-card-overlap-plain", None) };
619 Some(Timed { line, from: ms(&from), until: o.first.end.max(o.second.end) * 1000 })
620 })
621 .collect()
622}
623
624fn timeline(m: &Moment) -> Vec<(Zoned, Zoned, Mode)> {
632 let Some(horizon) = midnight_after(m.now).and_then(|z| midnight_after(&z)) else { return Vec::new() };
633 let hours = m.config.week_hours();
634 let zone = m.now.time_zone().clone();
635 let instant = |seconds: i64| Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()));
636 let rests = m.overrides.porch_rests_until.and_then(instant);
637 let quiet_end = m.dnd.and_then(|d| d.until).filter(|until| *until > 0).and_then(instant);
639 let kept: Vec<Zoned> = m.blocks.kept.iter().flat_map(|k| [k.start, k.at, k.end]).filter_map(instant).chain(quiet_end).collect();
640 let mut frames = Vec::new();
641 let mut at = m.now.clone();
642 while at.timestamp() < horizon.timestamp() && frames.len() < FRAMES {
643 let mode = quiet::mode(&hours, &m.config.time_off, m.overrides, m.blocks, &at);
644 let mut ends = vec![horizon.clone()];
645 ends.extend(midnight_after(&at));
646 ends.extend(mode.until.clone());
647 ends.extend(kept.iter().cloned());
648 ends.extend(window::current(&m.config.windows, &at).map(|(_, closing)| closing));
649 ends.extend(window::next_opening(&m.config.windows, &at));
650 ends.extend(rests.clone());
651 let end = ends
652 .into_iter()
653 .filter(|e| e.timestamp() > at.timestamp())
654 .min_by_key(Zoned::timestamp)
655 .unwrap_or_else(|| at.checked_add(Span::new().minutes(1)).unwrap_or_else(|_| horizon.clone()));
656 frames.push((at.clone(), end.clone(), mode));
657 at = end;
658 }
659 frames
660}
661
662fn kind(mode: &Mode) -> &'static str {
664 match mode.reason {
665 Reason::Paused => "paused",
666 Reason::FreeTime => "free-time",
667 _ => mode.time.id(),
668 }
669}
670
671fn status(m: &Moment, mode: &Mode, at: &Zoned) -> String {
674 let until = mode.until.as_ref().map(|z| quiet::until_text(m.tr, z, at)).unwrap_or_default();
675 let id = match mode.reason {
676 Reason::FreeTime => return m.tr.text(if pause::nothing_now(m.overrides, &m.config.free_time) { "mode-free-time-nothing" } else { "mode-free-time" }, None),
677 Reason::Paused => return m.tr.text("mode-paused", None),
678 _ if until.is_empty() => return String::new(),
680 Reason::NoHours => return String::new(),
681 Reason::Working | Reason::Extended => "home-card-work",
682 Reason::TimeOff => "mode-time-off",
683 Reason::WorkingLate => "mode-working-late",
684 Reason::WorkNow => "mode-work-now-line",
685 Reason::AdminTime => "mode-admin",
686 Reason::DoneForTheDay => "mode-quiet",
687 Reason::Evening | Reason::DayOff => "mode-leisure",
688 Reason::Meal => "mode-meal",
689 Reason::WindingDown => "mode-wind-down",
690 Reason::Nap => "mode-nap",
691 Reason::Sleep => "mode-sleep",
692 };
693 let mut args = i18n::args();
694 args.set("until", until);
695 args.set("label", if mode.label.trim().is_empty() { "none".to_string() } else { mode.label.clone() });
696 m.tr.text(id, Some(&args))
697}
698
699fn day_short(tr: &Translator, day: Date) -> String {
701 let mut args = i18n::args();
702 args.set("weekday", tr.weekday_short(day));
703 args.set("day", day.day());
704 args.set("month", tr.month_short(day));
705 tr.text("date-day", Some(&args))
706}
707
708fn day_month_short(tr: &Translator, day: Date, today: Date) -> String {
711 let mut args = i18n::args();
712 args.set("day", day.day());
713 args.set("month", tr.month_short(day));
714 let said = tr.text("date-day-month", Some(&args));
715 if day.year() == today.year() { said } else { format!("{said} {}", day.year()) }
716}
717
718fn mail_when(tr: &Translator, sent: Option<i64>, now: &Zoned) -> Vec<Label> {
722 let Some(at) = sent.and_then(|s| Timestamp::from_second(s).ok()).map(|t| t.to_zoned(now.time_zone().clone())) else { return Vec::new() };
723 let (day, today) = (at.date(), now.date());
724 let first = midnight_after(now);
725 let second = first.as_ref().and_then(midnight_after);
726 let yesterday = tr.text("home-card-yesterday", None);
727 let mut labels = Vec::new();
728 match (first, second) {
729 (Some(first), Some(second)) if day == today => {
730 labels.push(Label { until: ms(&first), text: at.strftime("%H:%M").to_string() });
731 labels.push(Label { until: ms(&second), text: yesterday });
732 }
733 (Some(first), _) if today.yesterday().is_ok_and(|y| y == day) => labels.push(Label { until: ms(&first), text: yesterday }),
734 _ => {}
735 }
736 labels.push(Label { until: 0, text: day_month_short(tr, day, today) });
737 labels
738}
739
740fn mail_line(m: &Moment, item: &view::ItemView, triaged: Option<&Triaged>) -> (i64, Mail) {
745 let sent = triaged.and_then(|t| t.card.date);
746 let account = item.account.as_deref().and_then(|id| m.config.every_account().position(|a| a.id == id)).and_then(|at| i32::try_from(at).ok()).unwrap_or(-1);
747 let unread = !sioul_core::maildir::flags_of(std::path::Path::new(&item.key)).contains('S');
749 let text: String = item.preview.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(TEXT_CHARS).collect();
750 let subject = if item.subject.trim().is_empty() { m.tr.text("mail-no-subject", None) } else { item.subject.clone() };
751 (sent.unwrap_or(0), Mail { key: item.key.clone(), sender: item.sender.clone(), subject, text, unread, account, when: mail_when(m.tr, sent, m.now) })
752}
753
754fn porch_at(m: &Moment, input: &PorchInput, mode: &Mode, at: &Zoned) -> PorchMade {
762 if let Some(until) = m.overrides.porch_rests_until.filter(|_| m.overrides.porch_rests(at.timestamp().as_second())).and_then(|u| Timestamp::from_second(u).ok()) {
763 let until = until.to_zoned(at.time_zone().clone());
764 return PorchMade::said(said(m.tr, "home-card-porch-rests", &[("when", when(m.tr, &until, at))]));
765 }
766 let attention = sioul_core::attention::Attention::of(m.config);
767 let mut moment = sioul_core::attention::Now::of(mode);
768 moment.nothing = mode.free() && pause::nothing_now(m.overrides, &m.config.free_time);
769 let always = sioul_core::everywhere::People::load(&sioul_core::everywhere::People::default_path());
770 let area_of = |t: &Triaged| t.card.account.as_deref().and_then(|id| m.config.account(id)).and_then(|a| a.area.as_deref()).and_then(Area::parse).unwrap_or(Area::WORK);
771 let mail: Vec<Triaged> = input.items.iter().filter(|t| attention.mail(t, &input.senders, &always, area_of(t), &moment).shown).cloned().collect();
772 let shown = view::porch(&mail, m.config, input.store.as_ref(), m.tr, at, mode.quiet);
773 if !shown.open {
774 return PorchMade::said(match window::next_opening(&m.config.windows, at) {
775 Some(next) => said(m.tr, "home-card-porch-opens", &[("when", when(m.tr, &next, at))]),
776 None => m.tr.text("home-card-porch-closed", None),
777 });
778 }
779 let triaged: BTreeMap<String, &Triaged> = mail.iter().filter_map(|t| t.card.path.as_ref().map(|p| (p.display().to_string(), t))).collect();
783 let mut listed: Vec<(i64, Mail)> = shown
784 .lanes
785 .iter()
786 .filter(|l| !l.folded && !matches!(l.key.as_str(), "set-aside" | "review" | "hostile"))
787 .flat_map(|l| l.items.iter())
788 .filter(|i| !i.hidden && !i.spam)
789 .map(|i| mail_line(m, i, triaged.get(&i.key).copied()))
790 .collect();
791 if listed.is_empty() {
792 return PorchMade::said(m.tr.text("home-card-porch-empty", None));
793 }
794 if !m.details {
796 return PorchMade::said(m.tr.text("home-card-mail-waits", None));
797 }
798 listed.sort_by_key(|(sent, _)| std::cmp::Reverse(*sent));
800 PorchMade { line: String::new(), mail: listed.into_iter().take(MAIL).map(|(_, mail)| mail).collect() }
801}
802
803fn colour(color: Option<&str>) -> String {
806 let Some(color) = color.map(str::trim).filter(|c| c.starts_with('#')) else { return String::new() };
807 let hex: String = color.chars().skip(1).take(6).collect();
808 if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) { format!("#{}", hex.to_ascii_lowercase()) } else { String::new() }
809}
810
811fn event_time(tr: &Translator, event: &Occurrence, zone: &jiff::tz::TimeZone) -> String {
815 let at = |seconds: i64| Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()));
816 let (Some(start), Some(end)) = (at(event.start), at(event.end.max(event.start))) else { return String::new() };
817 let last = at(event.end.max(event.start + 1) - 1).map_or_else(|| start.date(), |z| z.date());
819 if event.all_day {
820 return if last > start.date() { said(tr, "home-card-until-day", &[("day", day_short(tr, last))]) } else { String::new() };
821 }
822 let from = start.strftime("%H:%M").to_string();
823 if event.end <= event.start {
824 return from;
825 }
826 let to = if last == start.date() { end.strftime("%H:%M").to_string() } else { tr.date(&end, true) };
827 said(tr, "home-card-time-range", &[("start", from), ("end", to)])
828}
829
830fn agenda_day(tr: &Translator, day: Date, today: Date, zone: &jiff::tz::TimeZone) -> Option<Day> {
833 let midnight = |d: Date| d.to_zoned(zone.clone()).ok().map(|z| ms(&z));
834 let (from, until) = (midnight(day)?, midnight(day.tomorrow().ok()?)?);
835 let name = day_short(tr, day);
836 let mut label = Vec::new();
837 if day > today.tomorrow().ok()? {
838 label.push(Label { until: midnight(day.yesterday().ok()?)?, text: name.clone() });
839 }
840 if day > today {
841 label.push(Label { until: from, text: said(tr, "home-card-tomorrow", &[("day", name.clone())]) });
842 }
843 label.push(Label { until, text: said(tr, "home-card-today", &[("day", name)]) });
844 Some(Day { from, until, label })
845}
846
847fn agenda_lines(m: &Moment, input: &AgendaInput) -> Option<AgendaLines> {
852 if !input.calendars {
853 return None;
854 }
855 let zone = m.now.time_zone().clone();
856 let today = m.now.date();
857 let now = m.now.timestamp().as_second();
858 let end = today.checked_add(Span::new().days(HORIZON_DAYS + 1)).ok().and_then(|d| d.to_zoned(zone.clone()).ok()).map_or(i64::MAX, |z| z.timestamp().as_second());
859 let mut coming: Vec<&Occurrence> = input.events.iter().filter(|e| !e.cancelled && e.end.max(e.start + 60) > now && e.start < end).collect();
861 coming.sort_by(|a, b| (a.start, !a.all_day, &a.summary).cmp(&(b.start, !b.all_day, &b.summary)));
863 let events = coming
864 .into_iter()
865 .take(EVENTS)
866 .map(|e| Event {
867 key: e.key.clone(),
868 title: match e.summary.trim() {
869 _ if !m.details => m.tr.text("home-card-event-plain", None),
870 "" => m.tr.text("agenda-untitled", None),
871 title => title.to_string(),
872 },
873 time: event_time(m.tr, e, &zone),
874 all_day: e.all_day,
875 start: e.start * 1000,
876 end: e.end.max(e.start + 60) * 1000,
877 color: colour(e.color.as_deref()),
878 })
879 .collect();
880 let days = (0..=HORIZON_DAYS).filter_map(|i| today.checked_add(Span::new().days(i)).ok()).filter_map(|day| agenda_day(m.tr, day, today, &zone)).collect();
881 Some(AgendaLines { empty: m.tr.text("home-card-agenda-empty", None), days, events })
882}
883
884fn time_for_a_step(mode: &Mode) -> bool {
887 matches!(mode.time, Time::Work | Time::Admin | Time::Several(_) | Time::Any) && !mode.sleeps() && !mode.paused() && !mode.free()
888}
889
890fn step_at(m: &Moment, input: &PlanInput, mode: &Mode, at: &Zoned) -> Option<Step> {
896 if !time_for_a_step(mode) {
897 return None;
898 }
899 let situation = quiet::Situation::now(m.config, m.overrides, m.blocks, at, m.tr, &input.loaded.projects);
900 let filter = Filter { quiet: situation.quiet_tasks(), ..Filter::default() };
901 let today = input.today.date == at.date().to_string();
902 let (weather, aside) = if today { (input.today.weather, input.today.aside.clone()) } else { (Weather::Clear, BTreeSet::new()) };
903 let cx = taskview::Context { filter: &filter, offices: situation.offices.clone(), tasks: &input.loaded.tasks, plan: &input.plan, today: at.date(), tr: m.tr, projects: &input.loaded.projects, spent: &input.spent, stopped: &input.stopped };
904 let now = taskview::now(&cx, weather, &aside);
905 let card = now.now?;
906 if m.details {
907 return Some(Step { title: card.title, why: now.why.first().cloned().unwrap_or_default(), uid: card.uid });
908 }
909 Some(Step { title: m.tr.text("home-card-step-plain", None), why: if card.due.is_empty() { card.estimate } else { card.due }, uid: card.uid })
911}
912
913fn kind_word(kind: CodeKind) -> &'static str {
915 match kind {
916 CodeKind::Code => "code",
917 CodeKind::Password => "password",
918 CodeKind::PasswordReset => "reset",
919 CodeKind::SignInLink => "link",
920 CodeKind::Confirmation => "confirm",
921 }
922}
923
924fn codes(m: &Moment, input: &PorchInput) -> Vec<Code> {
930 let now = m.now.timestamp().as_second();
931 let mut found: Vec<(i64, Code, CodeKind)> = input
932 .items
933 .iter()
934 .filter(|t| t.lane == Lane::RightNow)
935 .filter_map(|t| {
936 let code = t.code.as_ref()?;
937 let lasts = i64::from(code.lasts_minutes()) * 60;
938 let sent = porch::sent(&t.card).unwrap_or(now);
939 let until = sent + lasts;
940 let line = m.tr.right_now(code.kind, t.card.sender(), t.trust, code.code.as_deref(), None);
941 let warning = if t.trust == Trust::Verified { String::new() } else { m.tr.text("right-now-unverified", None) };
942 (until > now).then(|| (sent, Code { line, warning, until: until * 1000 }, code.kind))
943 })
944 .collect();
945 found.sort_by_key(|(sent, _, _)| std::cmp::Reverse(*sent));
946 if m.details {
947 return found.into_iter().map(|(_, code, _)| code).collect();
948 }
949 let last = found.iter().map(|(_, code, _)| code.until).max();
950 found.first().zip(last).map(|((_, _, kind), until)| Code { line: said(m.tr, "home-card-code-plain", &[("kind", kind_word(*kind).to_string())]), warning: String::new(), until }).into_iter().collect()
951}
952
953fn dose_lines(tr: &Translator, doses: &[Dose], details: bool) -> Vec<DoseLine> {
959 if !details {
960 return Vec::new();
961 }
962 doses
963 .iter()
964 .map(|d| {
965 let pairs = [("dose", d.name.clone()), ("time", d.time.clone())];
966 DoseLine { line: said(tr, "home-card-dose", &pairs), check: said(tr, "home-card-dose-check", &pairs), from: d.at * 1000, until: d.until * 1000, known_until: d.known_until * 1000 }
967 })
968 .collect()
969}
970
971fn labels(tr: &Translator, made: &Zoned) -> Vec<Label> {
974 let time = made.strftime("%H:%M").to_string();
975 let first = midnight_after(made);
976 let second = first.as_ref().and_then(midnight_after);
977 vec![
978 Label { until: first.as_ref().map_or(0, ms), text: said(tr, "home-card-when-at", &[("time", time.clone())]) },
979 Label { until: second.as_ref().map_or(0, ms), text: said(tr, "home-card-when-yesterday", &[("time", time)]) },
980 Label { until: 0, text: tr.date(made, true) },
981 ]
982}
983
984fn porch_key(m: &Moment, mode: &Mode, at: &Zoned) -> String {
988 let stamp = at.timestamp().as_second();
989 let open = mode.quiet || m.config.windows.is_empty() || window::current(&m.config.windows, at).is_some();
990 let opens = if open { 0 } else { window::next_opening(&m.config.windows, at).map_or(-1, |z| z.timestamp().as_second()) };
991 format!("{}|{open}|{opens}|{}|{}", mode.time.id(), m.overrides.porch_rests(stamp), at.date())
992}
993
994#[cfg(test)]
997fn snapshot(m: &Moment, porch: Option<&PorchInput>, plan: Option<&PlanInput>, agenda: Option<&AgendaInput>, doses: &[Dose]) -> Snapshot {
998 snapshot_with(m, porch, plan, agenda, doses, &More::default())
999}
1000
1001fn snapshot_with(m: &Moment, porch: Option<&PorchInput>, plan: Option<&PlanInput>, agenda: Option<&AgendaInput>, doses: &[Dose], more: &More) -> Snapshot {
1003 let set_up = m.config.every_account().next().is_some();
1004 let mut made: BTreeMap<String, PorchMade> = BTreeMap::new();
1005 let mut mail: Vec<Mail> = Vec::new();
1007 let mut placed: BTreeMap<String, usize> = BTreeMap::new();
1008 let mut place = |message: &Mail| -> usize {
1009 if let Some(at) = placed.get(&message.key).filter(|_| !message.key.is_empty()) {
1010 return *at;
1011 }
1012 mail.push(message.clone());
1013 placed.insert(message.key.clone(), mail.len() - 1);
1014 mail.len() - 1
1015 };
1016 use sioul_core::attention::{Event, Kind as Own, Level, Now, Row};
1018 let attention = sioul_core::attention::Attention::of(m.config);
1019 let comes = |kind: Own, mode: &Mode| attention.level(Row::Own(kind), &Now::of(mode)) == Level::Now;
1020 let reminders = reminder_lines(m.tr, more.reminders, m.details, m.now.timestamp().as_second());
1023 let shows = |work: bool, mode: &Mode| !matches!(attention.decide(&Event::own(Own::Dates).for_area(work.then_some(Area::WORK)), &Now::of(mode)).level, Level::Later | Level::Never);
1024 let mut calls: Vec<String> = Vec::new();
1026 let mut listed_calls: BTreeMap<String, Vec<String>> = BTreeMap::new();
1027 let mut call_at = |mode: &Mode, from: &Zoned| -> Vec<usize> {
1028 let Some(lines_at) = more.calls else { return Vec::new() };
1029 if mode.paused() {
1030 return Vec::new();
1031 }
1032 let lines = listed_calls.entry(format!("{}|{}|{}", kind(mode), mode.time.id(), from.date())).or_insert_with(|| lines_at(&Now::of(mode), from)).clone();
1033 let lines = if m.details || lines.is_empty() { lines } else { vec![m.tr.text("home-card-calls-plain", None)] };
1034 lines
1035 .into_iter()
1036 .map(|line| match calls.iter().position(|c| *c == line) {
1037 Some(at) => at,
1038 None => {
1039 calls.push(line);
1040 calls.len() - 1
1041 }
1042 })
1043 .collect()
1044 };
1045 let frames: Vec<Frame> = timeline(m)
1046 .into_iter()
1047 .enumerate()
1048 .map(|(index, (from, until, mode))| {
1049 let rests = mode.sleeps() || mode.paused() || mode.free();
1051 let porch = if rests {
1052 None
1053 } else if !set_up {
1054 Some(PorchMade::said(m.tr.text("home-card-setup", None)))
1055 } else {
1056 porch.map(|p| made.entry(porch_key(m, &mode, &from)).or_insert_with(|| porch_at(m, p, &mode, &from)).clone())
1057 };
1058 let kind = kind(&mode);
1059 let step = plan.and_then(|p| step_at(m, p, &mode, &from));
1060 Frame {
1061 from: ms(&from),
1062 until: ms(&until),
1063 kind: kind.to_string(),
1064 status: status(m, &mode, &from),
1065 dnd: m.dnd.map(|d| d.line_in(kind, ms(&from), index == 0)).unwrap_or_default(),
1066 porch: porch.map(|p| PorchLines { line: p.line, mail: p.mail.iter().map(&mut place).collect() }),
1067 codes: !mode.paused() && comes(Own::Codes, &mode),
1070 doses: !mode.paused() && comes(Own::Doses, &mode),
1071 events: !mode.paused(),
1072 reminders: reminders.iter().enumerate().filter(|(_, (_, work))| !mode.paused() && shows(*work, &mode)).map(|(at, _)| at).collect(),
1073 calls: call_at(&mode, &from),
1074 overlaps: !rests,
1075 stopped: !rests && time_for_a_step(&mode),
1076 note: if set_up || rests { String::new() } else { m.tr.text("home-card-setup", None) },
1077 step,
1078 }
1079 })
1080 .collect();
1081 let made = ms(m.now);
1082 let end = frames.last().map_or(made, |f| f.until);
1083 let stopped = more.stopped.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(|text| if m.details { said(m.tr, "home-card-stopped", &[("text", text.to_string())]) } else { m.tr.text("home-card-stopped-plain", None) });
1084 Snapshot {
1085 v: VERSION,
1086 made,
1087 details: m.details,
1088 stale_after: made + FRESH_MS,
1089 stale: labels(m.tr, m.now),
1090 beyond: m.tr.text("home-card-beyond", None),
1091 words: Words { mail: m.tr.text("home-card-mail", None), agenda: m.tr.text("home-card-agenda", None) },
1092 today: today_lines(m, &more.weather, end),
1093 frames,
1094 mail,
1095 agenda: agenda.and_then(|a| agenda_lines(m, a)),
1096 codes: porch.map(|p| codes(m, p)).unwrap_or_default(),
1097 doses: dose_lines(m.tr, doses, m.details),
1098 reminders: reminders.into_iter().map(|(line, _)| line).collect(),
1099 calls,
1100 overlaps: agenda.map(|a| overlap_lines(m, a)).unwrap_or_default(),
1101 stopped: stopped.unwrap_or_default(),
1102 }
1103}
1104
1105fn content(snapshot: &Snapshot) -> String {
1107 let mut same = snapshot.clone();
1108 same.made = 0;
1109 same.stale_after = 0;
1110 same.stale.clear();
1111 if let Some(first) = same.frames.first_mut() {
1112 first.from = 0;
1113 }
1114 serde_json::to_string(&same).unwrap_or_default()
1115}
1116
1117fn on() -> bool {
1121 cfg!(target_os = "android") || std::env::var_os("SIOUL_HOME_CARD").is_some()
1122}
1123
1124fn path() -> PathBuf {
1125 sioul_core::config::state_dir().join(FILE)
1126}
1127
1128pub(crate) fn details() -> bool {
1130 !crate::work::WorkState::load().flags.get(PLAIN).copied().unwrap_or(false)
1131}
1132
1133static WRITING: Mutex<()> = Mutex::new(());
1135
1136fn write(text: &str) -> bool {
1138 let path = path();
1139 if let Some(parent) = path.parent() {
1140 let _ = std::fs::create_dir_all(parent);
1141 }
1142 let beside = path.with_extension("json.new");
1143 std::fs::write(&beside, text).and_then(|()| std::fs::rename(&beside, &path)).is_ok()
1144}
1145
1146fn draw() {
1149 #[cfg(target_os = "android")]
1150 unsafe {
1152 crate::backend::sioul_android_broadcast(c"com.aurelienpierre.sioul".as_ptr(), c"com.aurelienpierre.sioul.HomeCard".as_ptr(), c"com.aurelienpierre.sioul.action.HOME_CARD".as_ptr())
1153 };
1154}
1155
1156static PORCH: Mutex<Option<Arc<PorchInput>>> = Mutex::new(None);
1158static PLAN: Mutex<Option<Arc<PlanInput>>> = Mutex::new(None);
1159
1160struct Written {
1162 content: String,
1163 made: i64,
1164 first_ends: i64,
1165}
1166
1167static WRITTEN: Mutex<Option<Written>> = Mutex::new(None);
1168
1169pub(crate) fn porch_seen(items: &[Triaged], senders: &Senders, store: Option<&ProjectStore>) {
1172 if !on() {
1173 return;
1174 }
1175 *PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(PorchInput { items: items.to_vec(), senders: senders.clone(), store: store.cloned() }));
1176 soon(false);
1177}
1178
1179pub(crate) fn plan_seen(loaded: &Arc<Loaded>, plan: &Plan, today: &Today, spent: &BTreeMap<String, u32>, stopped: &BTreeMap<String, String>) {
1181 if !on() {
1182 return;
1183 }
1184 *PLAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(PlanInput { loaded: Arc::clone(loaded), plan: plan.clone(), today: today.clone(), spent: spent.clone(), stopped: stopped.clone() }));
1185 soon(false);
1186}
1187
1188static AGENDA: Mutex<Option<Arc<AgendaInput>>> = Mutex::new(None);
1190type Hidden = (BTreeSet<PathBuf>, BTreeSet<(PathBuf, i64)>);
1192static HIDDEN: Mutex<Option<Hidden>> = Mutex::new(None);
1193static AGENDA_AGAIN: AtomicBool = AtomicBool::new(true);
1195
1196pub(crate) fn agenda_seen(removed: &BTreeSet<PathBuf>, skipped: &BTreeSet<(PathBuf, i64)>) {
1200 if !on() {
1201 return;
1202 }
1203 *HIDDEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some((removed.clone(), skipped.clone()));
1204 AGENDA_AGAIN.store(true, Ordering::SeqCst);
1205 soon(false);
1206}
1207
1208fn agenda_input(now: &Zoned) -> Option<Arc<AgendaInput>> {
1211 let mut kept = AGENDA.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1212 let again = AGENDA_AGAIN.swap(false, Ordering::SeqCst);
1213 if !again && let Some(input) = kept.as_ref().filter(|a| a.read_on == now.date()) {
1214 return Some(Arc::clone(input));
1215 }
1216 let midnight = now.date().to_zoned(now.time_zone().clone()).ok()?.timestamp().as_second();
1217 let reach = midnight + (HORIZON_DAYS + 1) * 86_400 + 3_600;
1218 let (removed, skipped) = HIDDEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone().unwrap_or_default();
1219 let events = sioul_core::agenda::occurrences(midnight, reach)
1220 .into_iter()
1221 .filter(|e| !removed.contains(std::path::Path::new(&e.key)) && !skipped.contains(&(PathBuf::from(&e.key), e.start)))
1222 .collect();
1223 let calendars = !sioul_core::vdir::collections(sioul_core::vdir::Kind::Calendars).is_empty();
1224 let aside = sioul_core::overlaps::SetAside::load(&sioul_core::overlaps::SetAside::default_path()).keys;
1225 let input = Arc::new(AgendaInput { events, calendars, read_on: now.date(), aside });
1226 *kept = Some(Arc::clone(&input));
1227 Some(input)
1228}
1229
1230static REMINDED: Mutex<Option<Arc<Vec<Reminder>>>> = Mutex::new(None);
1232
1233pub(crate) fn reminders_seen(all: &[Reminder]) {
1236 if !on() {
1237 return;
1238 }
1239 use sioul_core::reminders::Kind;
1240 let kept: Vec<Reminder> = all.iter().filter(|r| !matches!(r.kind, Kind::Event | Kind::Before | Kind::Alarm)).cloned().collect();
1241 let mut seen = REMINDED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1242 if seen.as_deref() == Some(&kept) {
1243 return;
1244 }
1245 *seen = Some(Arc::new(kept));
1246 drop(seen);
1247 soon(false);
1248}
1249
1250pub(crate) fn weather_seen() {
1252 if on() {
1253 soon(false);
1254 }
1255}
1256
1257fn weather_input() -> WeatherInput {
1259 let config = load_config();
1260 let place = config.weather.place.as_deref().is_some_and(|p| !p.trim().is_empty()) && config.weather.latitude.is_some() && config.weather.longitude.is_some();
1261 let forecast = place.then(|| std::fs::read_to_string(crate::backend::weather_cache()).ok().and_then(|t| serde_json::from_str(&t).ok())).flatten();
1262 WeatherInput { place, forecast }
1263}
1264
1265pub(crate) fn tick() {
1268 if !on() {
1269 return;
1270 }
1271 let now = Timestamp::now().as_second();
1272 let due = WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().is_none_or(|w| now >= w.first_ends || now - w.made >= REWRITE_EVERY);
1273 if due {
1274 soon(false);
1275 }
1276}
1277
1278pub(crate) fn now() {
1280 if on() {
1281 soon(true);
1282 }
1283}
1284
1285static DND: Mutex<Option<serde_json::Value>> = Mutex::new(None);
1287
1288pub(crate) fn dnd_seen(moment: serde_json::Value) {
1295 if !on() {
1296 return;
1297 }
1298 {
1299 let mut kept = DND.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1300 if kept.as_ref() == Some(&moment) {
1301 return;
1302 }
1303 *kept = Some(moment.clone());
1304 }
1305 let window = PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_some() && PLAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_some();
1306 if window {
1307 soon(true);
1308 return;
1309 }
1310 let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1311 let Some(mut card) = std::fs::read_to_string(path()).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()).filter(serde_json::Value::is_object) else { return };
1312 if patch_dnd(&mut card, Dnd::of(&moment).as_ref(), Timestamp::now().as_millisecond()) && write(&card.to_string()) {
1313 draw();
1314 }
1315}
1316
1317fn patch_dnd(card: &mut serde_json::Value, dnd: Option<&Dnd>, now: i64) -> bool {
1321 let Some(frames) = frames_of(card) else { return false };
1322 let before = frames.clone();
1323 if let Some(end) = dnd.and_then(|d| d.until).filter(|u| *u > 0).map(|u| u * 1000)
1325 && let Some(at) = frames.iter().position(|f| f["from"].as_i64().is_some_and(|from| from < end) && f["until"].as_i64().is_some_and(|until| end < until))
1326 {
1327 let mut after = frames[at].clone();
1328 after["from"] = end.into();
1329 frames[at]["until"] = end.into();
1330 frames.insert(at + 1, after);
1331 }
1332 let current = frames.iter().position(|f| f["from"].as_i64().unwrap_or(0) <= now && now < f["until"].as_i64().unwrap_or(0)).unwrap_or(0);
1334 for (index, frame) in frames.iter_mut().enumerate() {
1335 let (kind, from) = (frame["kind"].as_str().unwrap_or_default().to_string(), frame["from"].as_i64().unwrap_or(0));
1336 frame["dnd"] = dnd.map(|d| d.line_in(&kind, from, index == current)).unwrap_or_default().into();
1337 }
1338 *frames != before
1339}
1340
1341fn frames_of(card: &mut serde_json::Value) -> Option<&mut Vec<serde_json::Value>> {
1343 let key = if card["v"].as_u64().is_some_and(|v| v >= 3) { "times" } else { "frames" };
1344 card[key].as_array_mut()
1345}
1346
1347static AGAIN: AtomicBool = AtomicBool::new(false);
1349static RUNNING: AtomicBool = AtomicBool::new(false);
1350static TELL: AtomicBool = AtomicBool::new(false);
1351
1352fn soon(tell: bool) {
1354 if tell {
1355 TELL.store(true, Ordering::SeqCst);
1356 }
1357 AGAIN.store(true, Ordering::SeqCst);
1358 if RUNNING.swap(true, Ordering::SeqCst) {
1359 return;
1360 }
1361 std::thread::spawn(|| {
1362 loop {
1363 while AGAIN.swap(false, Ordering::SeqCst) {
1364 let tell = TELL.swap(false, Ordering::SeqCst);
1365 let _ = std::panic::catch_unwind(|| refresh(tell));
1367 }
1368 RUNNING.store(false, Ordering::SeqCst);
1369 if !AGAIN.load(Ordering::SeqCst) || RUNNING.swap(true, Ordering::SeqCst) {
1370 break;
1371 }
1372 }
1373 });
1374}
1375
1376fn refresh(tell: bool) {
1382 let porch = PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
1383 let plan = PLAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
1384 let (Some(porch), Some(plan)) = (porch, plan) else { return };
1385 let now = Zoned::now();
1386 let config = load_config();
1387 let overrides = Overrides::load(&Overrides::default_path());
1388 let blocks = crate::hours::blocks(&now);
1389 let details = details();
1390 let doses = if details { crate::health::card_doses(&now) } else { Vec::new() };
1391 let dnd = DND.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(Dnd::of);
1392 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: tr(), details, dnd: dnd.as_ref() };
1393 let agenda = agenda_input(&now);
1394 let reminded = REMINDED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone().unwrap_or_default();
1395 let calls = crate::calls::for_card();
1396 let stopped = sioul_core::stopped::Stopped::load(&sioul_core::stopped::Stopped::default_path()).map(|s| s.text);
1397 let more = More { weather: weather_input(), reminders: &reminded, calls: calls.as_deref(), stopped };
1398 let card = snapshot_with(&moment, Some(&porch), Some(&plan), agenda.as_deref(), &doses, &more);
1399 let said = content(&card);
1400 let Ok(text) = serde_json::to_string(&card) else { return };
1401 let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1402 let mut written = WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1403 let changed = written.as_ref().is_none_or(|w| w.content != said);
1404 if !write(&text) {
1405 return;
1406 }
1407 let first_ends = card.frames.first().map_or(0, |f| f.until / 1000);
1408 *written = Some(Written { content: said, made: now.timestamp().as_second(), first_ends });
1409 drop(written);
1410 if changed || tell {
1411 draw();
1412 }
1413}
1414
1415pub(crate) fn doses_changed() {
1419 if !on() {
1420 return;
1421 }
1422 let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1423 let Some(mut card) = std::fs::read_to_string(path()).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()).filter(serde_json::Value::is_object) else { return };
1424 let details = details();
1425 let now = Zoned::now();
1426 let doses = if details { crate::health::card_doses(&now) } else { Vec::new() };
1427 let Ok(lines) = serde_json::to_value(dose_lines(tr(), &doses, details)) else { return };
1428 if card["doses"] == lines {
1429 return;
1430 }
1431 card["doses"] = lines;
1432 if write(&card.to_string()) {
1433 draw();
1434 }
1435}
1436
1437pub(crate) fn mail_came() {
1443 if !on() || PORCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_some() {
1444 return;
1445 }
1446 let config = load_config();
1447 let now = Zoned::now();
1448 let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
1450 let store = config.notes_root_path().and_then(|root| ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
1451 let known = porch::KnownSenders::load(&config.known_senders_path());
1452 let senders = Senders::load(&config);
1453 let state = sioul_core::state::PorchState::load(&sioul_core::state::PorchState::default_path());
1454 let items = porch::gather(&config.mail_sources(), store.as_ref(), &known, &senders, &state, now.timestamp().as_second());
1455 let input = PorchInput { items, senders, store };
1456 let overrides = Overrides::load(&Overrides::default_path());
1457 let blocks = crate::hours::blocks(&now);
1458 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: tr(), details: details(), dnd: None };
1459 let _writing = WRITING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1460 let Some(mut card) = std::fs::read_to_string(path()).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()).filter(serde_json::Value::is_object) else { return };
1461 if patch_mail(&mut card, &moment, &input) && write(&card.to_string()) {
1462 draw();
1463 }
1464}
1465
1466fn patch_mail(card: &mut serde_json::Value, m: &Moment, input: &PorchInput) -> bool {
1471 if card["v"] != VERSION {
1472 return false;
1473 }
1474 let before = card.clone();
1475 let hours = m.config.week_hours();
1476 let zone = m.now.time_zone().clone();
1477 let mut made: BTreeMap<String, PorchMade> = BTreeMap::new();
1478 let mut mail: Vec<Mail> = Vec::new();
1479 let mut placed: BTreeMap<String, usize> = BTreeMap::new();
1480 let Some(frames) = frames_of(card) else { return false };
1481 for frame in frames.iter_mut().filter(|f| f["porch"].is_object()) {
1482 let Some(from) = frame["from"].as_i64().and_then(|at| Timestamp::from_millisecond(at).ok()).map(|t| t.to_zoned(zone.clone())) else { continue };
1483 let mode = quiet::mode(&hours, &m.config.time_off, m.overrides, m.blocks, &from);
1484 let porch = made.entry(porch_key(m, &mode, &from)).or_insert_with(|| porch_at(m, input, &mode, &from)).clone();
1485 let at: Vec<usize> = porch
1486 .mail
1487 .iter()
1488 .map(|message| match placed.get(&message.key).filter(|_| !message.key.is_empty()) {
1489 Some(at) => *at,
1490 None => {
1491 mail.push(message.clone());
1492 placed.insert(message.key.clone(), mail.len() - 1);
1493 mail.len() - 1
1494 }
1495 })
1496 .collect();
1497 let Ok(lines) = serde_json::to_value(PorchLines { line: porch.line, mail: at }) else { return false };
1498 frame["porch"] = lines;
1499 }
1500 let (Ok(mail), Ok(codes)) = (serde_json::to_value(mail), serde_json::to_value(codes(m, input))) else { return false };
1501 card["mail"] = mail;
1502 card["codes"] = codes;
1503 *card != before
1504}
1505
1506pub(crate) fn opened() -> Option<(&'static str, String, String)> {
1513 let path = sioul_core::config::state_dir().join(OPENED);
1514 let text = std::fs::read_to_string(&path).ok()?;
1515 let _ = std::fs::remove_file(&path);
1516 tap(&text, Timestamp::now().as_millisecond())
1517}
1518
1519fn tap(text: &str, now: i64) -> Option<(&'static str, String, String)> {
1525 let tap: serde_json::Value = serde_json::from_str(text).ok()?;
1526 let at = tap["at"].as_i64()?;
1527 if now - at > TAP_KEPT_MS || at - now > 60_000 {
1528 return None;
1529 }
1530 let key = tap["key"].as_str().unwrap_or_default().to_string();
1531 match tap["open"].as_str()? {
1532 "porch" => Some(("porch", String::new(), String::new())),
1533 "now" => Some(("now", tap["uid"].as_str().filter(|u| !u.is_empty()).map(sioul_core::links::task_uri).unwrap_or_default(), String::new())),
1534 "mail" if !key.is_empty() => Some(("card-mail", String::new(), key)),
1535 "mail" => Some(("porch", String::new(), String::new())),
1536 "event" if !key.is_empty() => Some(("event", String::new(), key)),
1537 "event" | "agenda" => Some(("agenda", String::new(), String::new())),
1538 "reminder" => Some(reminder_opens(&key)),
1539 _ => None,
1540 }
1541}
1542
1543fn reminder_opens(target: &str) -> (&'static str, String, String) {
1547 if let Some(uid) = target.strip_prefix("sioul:task/") {
1548 ("task", target.to_string(), uid.to_string())
1549 } else if target.starts_with("sioul:budget/") {
1550 ("budget", target.to_string(), String::new())
1551 } else if let Some(id) = target.strip_prefix("sioul:paper/") {
1552 ("paper", target.to_string(), id.to_string())
1553 } else if let Some(id) = target.strip_prefix("sioul:contract/") {
1554 ("contract", target.to_string(), id.to_string())
1555 } else {
1556 ("porch", String::new(), String::new())
1557 }
1558}
1559
1560pub(crate) fn setting(group: &str, section: &str, on: bool) -> sioul_core::settings::Setting {
1562 sioul_core::settings::Setting {
1563 key: "home_card_details".into(),
1564 kind: sioul_core::settings::Kind::Bool,
1565 label: tr().text("set-home-card-details", None),
1566 help: tr().text("set-home-card-details-help", None),
1567 value: sioul_core::config::SettingValue::Bool(on),
1568 choices: Vec::new(),
1569 rows: Vec::new(),
1570 min: 0.0,
1571 max: 0.0,
1572 step: 1.0,
1573 unit: String::new(),
1574 group: group.to_string(),
1575 section: section.to_string(),
1576 }
1577}
1578
1579pub(crate) fn has_setting() -> bool {
1581 on()
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586 use super::*;
1587 use sioul_core::card::Card;
1588 use sioul_core::codes::OneTimeCode;
1589 use sioul_core::config::Priority;
1590 use sioul_core::needs::{Days, Needs};
1591 use sioul_core::plan::Settings;
1592 use sioul_core::reminders;
1593 use sioul_core::tasks::Task;
1594
1595 fn config() -> Config {
1597 toml::from_str(
1598 "[[account]]\nid = \"me\"\naddress = \"me@example.org\"\n\n\
1599 [[window]]\nday = \"monday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1600 [[window]]\nday = \"tuesday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1601 [[window]]\nday = \"wednesday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1602 [[window]]\nday = \"thursday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1603 [[window]]\nday = \"friday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n\
1604 [[window]]\nday = \"tuesday\"\nstart = \"17:30\"\nend = \"18:30\"\nkind = \"admin\"\n",
1605 )
1606 .unwrap()
1607 }
1608
1609 fn blocks(now: &Zoned) -> Blocks {
1611 let needs = Needs { meals_on: true, sleep_on: true, ..Needs::default() };
1612 Blocks::of(&needs, &Days::default(), &[], now)
1613 }
1614
1615 fn at(text: &str) -> Zoned {
1616 text.parse().unwrap()
1617 }
1618
1619 fn message(from: &str, subject: &str, lane: Lane) -> Triaged {
1621 let raw = format!("From: {from}\r\nSubject: {subject}\r\nDate: Mon, 05 Oct 2026 08:00:00 +0200\r\n\r\nHello,\r\n\r\n the text on several\r\nlines.\r\n");
1622 let mut card = Card::from_bytes(raw.as_bytes()).unwrap();
1623 let name: String = format!("{from}-{subject}").chars().filter(char::is_ascii_alphanumeric).collect();
1624 card.path = Some(PathBuf::from(format!("/mail/me/INBOX/new/1759600000.{name}.sioul")));
1625 card.account = Some("me".into());
1626 Triaged { card, lane, trust: Trust::Verified, code: None, reasons: Vec::new(), priority: Priority::Average, checks: None, assessment: None }
1627 }
1628
1629 fn sent_at(from: &str, subject: &str, sent: i64, read: bool) -> Triaged {
1631 let mut letter = message(from, subject, Lane::People);
1632 letter.card.date = Some(sent);
1633 if read {
1634 let name = letter.card.path.as_ref().unwrap().file_name().unwrap().to_string_lossy().to_string();
1635 letter.card.path = Some(PathBuf::from(format!("/mail/me/INBOX/cur/{name}:2,S")));
1636 }
1637 letter
1638 }
1639
1640 fn porch_input(now: &Zoned) -> PorchInput {
1642 let mut items: Vec<Triaged> = [("Marie Dupont <marie@example.org>", "Dinner on Saturday"), ("Paul <paul@example.org>", "The photos"), ("Léa <lea@example.org>", "Sunday"), ("Tom <tom@example.org>", "A question")]
1643 .into_iter()
1644 .map(|(from, subject)| message(from, subject, Lane::People))
1645 .collect();
1646 let mut code = message("La Banque <no-reply@banque.example>", "Votre code", Lane::RightNow);
1647 code.code = Some(OneTimeCode { kind: CodeKind::Code, code: Some("482913".into()), expires_minutes: Some(10) });
1648 code.card.date = Some(now.timestamp().as_second() - 120);
1650 items.push(code);
1651 let senders = Senders { safe: sioul_core::porch::SenderList::parse("marie@example.org\npaul@example.org\nlea@example.org\ntom@example.org\n"), ..Senders::default() };
1653 PorchInput { items, senders, store: None }
1654 }
1655
1656 fn task(uid: &str, title: &str) -> Task {
1657 Task { uid: uid.into(), title: title.into(), estimate: 15, ..Task::default() }
1658 }
1659
1660 fn plan_input(now: &Zoned) -> PlanInput {
1661 let mut form = task("form", "Call the CAF about the housing aid");
1662 form.due = "2026-10-30".into();
1663 form.area = "work".into();
1665 let tasks = vec![form];
1666 let plan = sioul_core::plan::plan(&tasks, now.date(), &Settings::default(), &BTreeMap::new(), &BTreeSet::new());
1667 PlanInput { loaded: Arc::new(Loaded { tasks, ..Loaded::default() }), plan, today: Today { date: now.date().to_string(), ..Today::default() }, spent: BTreeMap::new(), stopped: BTreeMap::new() }
1668 }
1669
1670 struct Made {
1671 card: Snapshot,
1672 }
1673
1674 impl Made {
1675 fn at(&self, when: &str) -> &Frame {
1677 let t = at(when).timestamp().as_millisecond();
1678 self.card.frames.iter().find(|f| f.from <= t && t < f.until).unwrap_or_else(|| panic!("no frame at {when}: {:#?}", self.card.frames))
1679 }
1680 }
1681
1682 fn events_of(events: &[(&str, &str, &str, bool, Option<&str>, bool)]) -> AgendaInput {
1686 let events = events
1687 .iter()
1688 .enumerate()
1689 .map(|(n, (title, start, end, all_day, color, cancelled))| Occurrence {
1690 key: format!("/calendars/me/personal/event-{n}.ics"),
1691 summary: title.to_string(),
1692 start: at(start).timestamp().as_second(),
1693 end: at(end).timestamp().as_second(),
1694 all_day: *all_day,
1695 calendar: "Personal".into(),
1696 color: color.map(str::to_string),
1697 cancelled: *cancelled,
1698 ..Occurrence::default()
1699 })
1700 .collect();
1701 AgendaInput { events, calendars: true, read_on: Date::constant(2026, 10, 5), aside: BTreeSet::new() }
1702 }
1703
1704 fn some_events() -> AgendaInput {
1706 events_of(&[
1707 ("Dentist", "2026-10-06T11:00[Europe/Paris]", "2026-10-06T12:00[Europe/Paris]", false, Some("#4C6B5CFF"), false),
1708 ("Choir", "2026-10-05T19:00[Europe/Paris]", "2026-10-05T20:30[Europe/Paris]", false, None, false),
1709 ("Neighbours' party", "2026-10-12T00:00[Europe/Paris]", "2026-10-13T00:00[Europe/Paris]", true, Some("#b07d3f"), false),
1710 ("Trip to the sea", "2026-10-14T00:00[Europe/Paris]", "2026-10-17T00:00[Europe/Paris]", true, None, false),
1711 ("Night train", "2026-10-20T22:30[Europe/Paris]", "2026-10-21T06:45[Europe/Paris]", false, None, false),
1712 ("A call", "2026-10-07T09:00[Europe/Paris]", "2026-10-07T09:30[Europe/Paris]", false, None, true),
1713 ])
1714 }
1715
1716 fn make(now: &str, overrides: &Overrides, details: bool, language: &str, doses: &[Dose]) -> Made {
1717 let now = at(now);
1718 let config = config();
1719 let tr = Translator::new(language);
1720 let blocks = blocks(&now);
1721 let moment = Moment { now: &now, config: &config, overrides, blocks: &blocks, tr: &tr, details, dnd: None };
1722 let porch = porch_input(&now);
1723 let plan = plan_input(&now);
1724 Made { card: snapshot(&moment, Some(&porch), Some(&plan), Some(&some_events()), doses) }
1725 }
1726
1727 impl Made {
1728 fn senders(&self, frame: &Frame) -> Vec<&str> {
1730 frame.porch.iter().flat_map(|p| p.mail.iter()).map(|at| self.card.mail[*at].sender.as_str()).collect()
1731 }
1732 }
1733
1734 fn words(card: &Snapshot, frame: &Frame) -> String {
1737 let mut out = vec![frame.status.clone(), frame.note.clone(), card.today.line.clone()];
1738 out.extend(card.today.date.iter().map(|l| l.text.clone()));
1739 if let Some(p) = &frame.porch {
1740 out.push(p.line.clone());
1741 for mail in p.mail.iter().map(|at| &card.mail[*at]) {
1742 out.extend([mail.sender.clone(), mail.subject.clone(), mail.text.clone()]);
1743 out.extend(mail.when.iter().map(|l| l.text.clone()));
1744 }
1745 }
1746 if let Some(s) = &frame.step {
1747 out.extend([s.title.clone(), s.why.clone()]);
1748 }
1749 if let Some(agenda) = card.agenda.as_ref().filter(|_| frame.events) {
1750 out.push(agenda.empty.clone());
1751 out.extend(agenda.days.iter().flat_map(|d| d.label.iter()).map(|l| l.text.clone()));
1752 out.extend(agenda.events.iter().flat_map(|e| [e.title.clone(), e.time.clone()]));
1753 }
1754 out.extend(card.words.mail.lines().chain(card.words.agenda.lines()).map(str::to_string));
1755 out.join("\n")
1756 }
1757
1758 #[test]
1759 fn each_time_says_its_own() {
1760 let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "en", &[]);
1762 let work = made.at("2026-10-05T10:00[Europe/Paris]");
1763 assert_eq!((work.kind.as_str(), work.status.as_str()), ("work", "Work until 12:10."));
1764 let porch = work.porch.as_ref().unwrap();
1765 assert_eq!(porch.line, "");
1768 assert_eq!(made.senders(work), ["Marie Dupont", "Paul", "Léa", "Tom"]);
1769 let first = &made.card.mail[porch.mail[0]];
1770 assert_eq!((first.subject.as_str(), first.text.as_str()), ("Dinner on Saturday", "Hello, the text on several lines."));
1771 let step = work.step.as_ref().unwrap();
1772 assert_eq!((step.title.as_str(), step.why.as_str(), step.uid.as_str()), ("Call the CAF about the housing aid", "Its date is 30 October: three weeks left.", "form"));
1773 assert!(work.codes && work.doses);
1774 assert_eq!(made.card.codes.len(), 1);
1776 assert_eq!(made.card.codes[0].line, "Code from La Banque (verified): 482913");
1777 assert_eq!(made.card.codes[0].warning, "");
1778 assert_eq!(made.card.codes[0].until, at("2026-10-05T10:08[Europe/Paris]").timestamp().as_millisecond());
1779 let meal = made.at("2026-10-05T12:30[Europe/Paris]");
1781 assert_eq!((meal.kind.as_str(), meal.status.as_str()), ("meals", "Meal until 13:00."));
1782 assert!(meal.step.is_none());
1783 assert_eq!(made.at("2026-10-05T15:00[Europe/Paris]").status, "Work until 17:00.");
1785 let evening = made.at("2026-10-05T17:30[Europe/Paris]");
1786 assert_eq!((evening.kind.as_str(), evening.status.as_str()), ("leisure", "Leisure until 19:00: what you enjoy."));
1787 assert!(evening.step.is_none() && evening.porch.is_some());
1788 assert_eq!(made.at("2026-10-05T22:15[Europe/Paris]").status, "Winding down: nothing disturbs until tomorrow at 07:00.");
1792 let night = made.at("2026-10-05T23:30[Europe/Paris]");
1793 assert_eq!((night.kind.as_str(), night.status.as_str()), ("sleep", "Sleep: nothing disturbs until tomorrow at 07:00."));
1794 assert!(night.porch.is_none() && night.step.is_none() && night.codes && night.doses);
1795 assert!(made.card.frames.iter().any(|f| f.from == at("2026-10-06T00:00[Europe/Paris]").timestamp().as_millisecond()));
1797 assert_eq!(made.at("2026-10-06T05:00[Europe/Paris]").status, "Sleep: nothing disturbs until 07:00.");
1798 let admin = made.at("2026-10-06T17:45[Europe/Paris]");
1800 assert_eq!((admin.kind.as_str(), admin.status.as_str()), ("admin", "Admin time until 18:30: offices, bills, letters."));
1801 assert!(admin.step.is_none());
1802 assert!(made.at("2026-10-06T09:30[Europe/Paris]").step.is_some());
1803 let last = made.card.frames.last().unwrap();
1805 assert_eq!(last.until, at("2026-10-07T00:00[Europe/Paris]").timestamp().as_millisecond());
1806 let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "fr", &[]);
1808 assert_eq!(fr.at("2026-10-06T05:00[Europe/Paris]").status, "Sommeil\u{202f}: rien ne dérange jusqu’à 07:00.");
1809 assert_eq!(fr.at("2026-10-05T23:30[Europe/Paris]").status, "Sommeil\u{202f}: rien ne dérange jusqu’à demain à 07:00.");
1810 assert_eq!(fr.at("2026-10-05T10:00[Europe/Paris]").status, "Travail jusqu’à 12:10.");
1811 }
1812
1813 #[test]
1814 fn a_pause_says_its_line_and_nothing_else() {
1815 let paused = Overrides { paused_since: Some(at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1816 let made = make("2026-10-05T10:00[Europe/Paris]", &paused, true, "en", &[dose("Magnesium · 300 mg", "09:45", "2026-10-05T09:45[Europe/Paris]", 0)]);
1817 let frame = made.at("2026-10-05T10:00[Europe/Paris]");
1818 assert_eq!((frame.kind.as_str(), frame.status.as_str()), ("paused", "Paused."));
1819 assert!(frame.porch.is_none() && frame.step.is_none() && !frame.codes && !frame.doses && !frame.events);
1820 assert!(made.card.frames.iter().all(|f| f.kind == "paused"));
1822 let free = Overrides { free_since: Some(at("2026-10-05T14:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1824 let made = make("2026-10-05T14:30[Europe/Paris]", &free, true, "en", &[]);
1825 let frame = made.at("2026-10-05T14:30[Europe/Paris]");
1826 assert_eq!(frame.kind, "free-time");
1827 assert_eq!(frame.status, "Free time: only your safe senders, doses and codes reach you. Work comes back when you do.");
1828 assert!(frame.porch.is_none() && frame.step.is_none() && frame.codes && frame.doses && frame.events);
1829 assert_eq!(made.at("2026-10-05T22:30[Europe/Paris]").kind, "sleep");
1831 }
1832
1833 #[test]
1834 fn the_porch_keeps_its_hours() {
1835 let rests = Overrides { porch_rests_until: Some(at("2026-10-06T09:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1837 let made = make("2026-10-05T15:00[Europe/Paris]", &rests, true, "en", &[]);
1838 let frame = made.at("2026-10-05T15:00[Europe/Paris]");
1839 let porch = frame.porch.as_ref().unwrap();
1840 assert_eq!(porch.line, "After the pause, the Porch opens tomorrow at 09:00.");
1841 assert!(porch.mail.is_empty());
1842 assert_eq!(made.at("2026-10-06T09:30[Europe/Paris]").porch.as_ref().unwrap().mail.len(), 4);
1844 let work_now = Overrides { work_now: Some(at("2026-10-05T20:30[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
1846 let made = make("2026-10-05T17:30[Europe/Paris]", &work_now, true, "en", &[]);
1847 let frame = made.at("2026-10-05T17:30[Europe/Paris]");
1848 assert_eq!(frame.status, "Work shown until 20:30, by your choice.");
1849 let porch = frame.porch.as_ref().unwrap();
1850 assert_eq!(porch.line, "The Porch opens tomorrow at 09:00.");
1851 assert!(porch.mail.is_empty(), "never mail waiting for later");
1852 assert_eq!(made.at("2026-10-05T19:30[Europe/Paris]").kind, "meals");
1854 assert_eq!(made.at("2026-10-05T20:15[Europe/Paris]").status, "Work shown until 20:30, by your choice.");
1855 let config = config();
1857 let tr = Translator::new("en");
1858 let now = at("2026-10-05T17:30[Europe/Paris]");
1859 let blocks = blocks(&now);
1860 let overrides = Overrides::default();
1861 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
1862 let mut input = porch_input(&now);
1863 input.items = vec![message("A stranger <someone@elsewhere.example>", "Offer", Lane::Screener)];
1864 let card = snapshot(&moment, Some(&input), None, None, &[]);
1865 let evening = card.frames.iter().find(|f| f.kind == "leisure").unwrap();
1866 assert_eq!(evening.porch.as_ref().unwrap().line, "Nothing waits on the Porch.");
1867 assert!(evening.porch.as_ref().unwrap().mail.is_empty());
1868 let work = card.frames.iter().find(|f| f.kind == "work").unwrap();
1869 assert_eq!(card.mail[work.porch.as_ref().unwrap().mail[0]].sender, "A stranger");
1870 }
1871
1872 #[test]
1873 fn mail_is_listed_as_a_mail_card_lists_it() {
1874 let now = at("2026-10-05T10:00[Europe/Paris]");
1875 let stamp = |text: &str| at(text).timestamp().as_second();
1876 let mut config = config();
1877 config.accounts.push(toml::from_str::<Config>("[[account]]\nid = \"club\"\naddress = \"me@club.example\"\n").unwrap().accounts.remove(0));
1878 let tr = Translator::new("en");
1879 let blocks = blocks(&now);
1880 let overrides = Overrides::default();
1881 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
1882 let mut input = porch_input(&now);
1883 let mut club = sent_at("Ana <ana@example.org>", "Rehearsal moved", stamp("2026-10-05T09:12[Europe/Paris]"), false);
1884 club.card.account = Some("club".into());
1885 let mut forged = sent_at("Bank <bank@bank.example>", "Your account", stamp("2026-10-05T09:50[Europe/Paris]"), false);
1887 forged.lane = Lane::SetAside;
1888 forged.priority = Priority::Above;
1889 let mut doubtful = sent_at("Offers <deals@shop.example>", "Big sale", stamp("2026-10-05T09:55[Europe/Paris]"), false);
1890 doubtful.lane = Lane::Review;
1891 input.items = vec![
1892 sent_at("Marie Dupont <marie@example.org>", "Dinner on Saturday", stamp("2026-10-04T18:30[Europe/Paris]"), true),
1893 club,
1894 sent_at("Paul <paul@example.org>", "The photos", stamp("2026-09-28T08:00[Europe/Paris]"), true),
1895 sent_at("Léa <lea@example.org>", "", stamp("2025-12-20T08:00[Europe/Paris]"), false),
1896 forged,
1897 doubtful,
1898 ];
1899 input.senders = Senders { safe: sioul_core::porch::SenderList::parse("marie@example.org\npaul@example.org\nlea@example.org\nana@example.org\n"), ..Senders::default() };
1900 let card = snapshot(&moment, Some(&input), None, None, &[]);
1901 let frame = &card.frames[0];
1902 let listed: Vec<&Mail> = frame.porch.as_ref().unwrap().mail.iter().map(|at| &card.mail[*at]).collect();
1903 assert_eq!(listed.iter().map(|m| m.sender.as_str()).collect::<Vec<_>>(), ["Ana", "Marie Dupont", "Paul", "Léa"]);
1905 let midnight = at("2026-10-06T00:00[Europe/Paris]").timestamp().as_millisecond();
1907 let next = at("2026-10-07T00:00[Europe/Paris]").timestamp().as_millisecond();
1908 let when = |m: &Mail| m.when.iter().map(|l| (l.until, l.text.clone())).collect::<Vec<_>>();
1909 assert_eq!(when(listed[0]), [(midnight, "09:12".to_string()), (next, "Yesterday".to_string()), (0, "5 Oct".to_string())]);
1910 assert_eq!(when(listed[1]), [(midnight, "Yesterday".to_string()), (0, "4 Oct".to_string())]);
1911 assert_eq!(when(listed[2]), [(0, "28 Sep".to_string())]);
1912 assert_eq!(when(listed[3]), [(0, "20 Dec 2025".to_string())]);
1914 assert_eq!(listed.iter().map(|m| m.unread).collect::<Vec<_>>(), [true, false, false, true]);
1916 assert_eq!((listed[0].account, listed[1].account), (1, 0));
1918 assert_eq!(listed[3].subject, "(no subject)");
1920 assert!(listed[0].key.ends_with(".sioul") && listed[0].key.contains("/new/"));
1922 let fr = Translator::new("fr");
1924 let first = sent_at("Ana <ana@example.org>", "Hello", stamp("2026-10-01T09:00[Europe/Paris]"), false);
1925 assert_eq!(mail_when(&fr, first.card.date, &now).last().unwrap().text, "1er oct.");
1926 assert_eq!(mail_when(&fr, Some(stamp("2026-10-04T09:00[Europe/Paris]")), &now)[0].text, "hier");
1927 let mut many = porch_input(&now);
1929 many.items = (0..30).map(|i| sent_at(&format!("Someone <s{i}@example.org>"), &format!("Note {i}"), stamp("2026-10-05T08:00[Europe/Paris]") - i * 60, false)).collect();
1930 many.senders = Senders { safe: sioul_core::porch::SenderList::parse("*@example.org\n"), ..Senders::default() };
1931 let card = snapshot(&moment, Some(&many), None, None, &[]);
1932 let shown = &card.frames[0].porch.as_ref().unwrap().mail;
1933 assert_eq!(shown.len(), 20);
1934 assert_eq!(card.mail[shown[0]].subject, "Note 0");
1935 assert_eq!(card.mail.len(), 20);
1937 }
1938
1939 #[test]
1940 fn details_off_names_no_one() {
1941 let doses = [dose("Magnesium · 300 mg", "09:45", "2026-10-05T09:45[Europe/Paris]", i64::MAX / 2000)];
1942 let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), false, "en", &doses);
1943 assert!(!made.card.details);
1944 let frame = made.at("2026-10-05T10:00[Europe/Paris]");
1945 assert_eq!(frame.status, "Work until 12:10.");
1946 let porch = frame.porch.as_ref().unwrap();
1948 assert_eq!(porch.line, "Mail waits on the Porch.");
1949 assert!(porch.mail.is_empty() && made.card.mail.is_empty());
1950 let step = frame.step.as_ref().unwrap();
1952 assert_eq!((step.title.as_str(), step.why.as_str()), ("Your next step", "By 30 October: three weeks left"));
1953 assert_eq!(made.card.codes.len(), 1);
1955 assert_eq!(made.card.codes[0].line, "A code waits on the Porch.");
1956 assert!(made.card.doses.is_empty());
1958 let agenda = made.card.agenda.as_ref().unwrap();
1960 assert!(agenda.events.iter().all(|e| e.title == "An event"));
1961 assert_eq!(agenda.events.iter().find(|e| e.start == at("2026-10-06T11:00[Europe/Paris]").timestamp().as_millisecond()).unwrap().time, "11:00 – 12:00");
1962 let all = serde_json::to_string(&made.card).unwrap();
1963 for private in ["Marie", "Dinner", "482913", "La Banque", "CAF", "Magnesium", "Dentist", "Choir", "party", "Trip"] {
1964 assert!(!all.contains(private), "{private} shown with the details off: {all}");
1965 }
1966 let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), false, "fr", &[]);
1967 assert_eq!(fr.card.codes[0].line, "Un code attend sur le Porche.");
1968 assert_eq!(fr.at("2026-10-05T10:00[Europe/Paris]").porch.as_ref().unwrap().line, "Du courrier attend sur le Porche.");
1969 assert!(fr.card.agenda.as_ref().unwrap().events.iter().all(|e| e.title == "Un événement"));
1970 }
1971
1972 #[test]
1973 fn the_agenda_comes_day_by_day() {
1974 let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "en", &[]);
1975 let ms = |text: &str| at(text).timestamp().as_millisecond();
1976 let agenda = made.card.agenda.as_ref().unwrap();
1977 let titles: Vec<&str> = agenda.events.iter().map(|e| e.title.as_str()).collect();
1979 assert_eq!(titles, ["Choir", "Dentist", "Neighbours' party", "Trip to the sea", "Night train"]);
1980 let event = |title: &str| agenda.events.iter().find(|e| e.title == title).unwrap();
1981 assert_eq!(event("Dentist").time, "11:00 – 12:00");
1983 assert_eq!(event("Night train").time, "22:30 – Wed 21 Oct 06:45");
1984 assert_eq!((event("Neighbours' party").time.as_str(), event("Neighbours' party").all_day), ("", true));
1985 assert_eq!(event("Trip to the sea").time, "Until Fri 16 Oct");
1986 assert_eq!((event("Dentist").color.as_str(), event("Choir").color.as_str(), event("Neighbours' party").color.as_str()), ("#4c6b5c", "", "#b07d3f"));
1988 assert_eq!((event("Dentist").start, event("Dentist").end), (ms("2026-10-06T11:00[Europe/Paris]"), ms("2026-10-06T12:00[Europe/Paris]")));
1989 assert_eq!(event("Dentist").key, "/calendars/me/personal/event-0.ics");
1990 assert_eq!(agenda.days.len(), 31);
1992 let names = |day: &Day| day.label.iter().map(|l| (l.until, l.text.clone())).collect::<Vec<_>>();
1993 assert_eq!(agenda.days[0].from, ms("2026-10-05T00:00[Europe/Paris]"));
1994 assert_eq!(names(&agenda.days[0]), [(ms("2026-10-06T00:00[Europe/Paris]"), "Today, Mon 5 Oct".to_string())]);
1995 assert_eq!(names(&agenda.days[1]), [(ms("2026-10-06T00:00[Europe/Paris]"), "Tomorrow, Tue 6 Oct".to_string()), (ms("2026-10-07T00:00[Europe/Paris]"), "Today, Tue 6 Oct".to_string())]);
1996 assert_eq!(
1997 names(&agenda.days[7]),
1998 [(ms("2026-10-11T00:00[Europe/Paris]"), "Mon 12 Oct".to_string()), (ms("2026-10-12T00:00[Europe/Paris]"), "Tomorrow, Mon 12 Oct".to_string()), (ms("2026-10-13T00:00[Europe/Paris]"), "Today, Mon 12 Oct".to_string())]
1999 );
2000 let long = agenda.days.iter().find(|d| d.from == ms("2026-10-25T00:00[Europe/Paris]")).unwrap();
2002 assert_eq!(long.until - long.from, 25 * 3_600_000);
2003 assert_eq!(agenda.empty, "Nothing in your calendars for the coming month.");
2004 assert!(made.card.frames.iter().all(|f| f.events));
2006 let later = make("2026-10-05T19:30[Europe/Paris]", &Overrides::default(), true, "en", &[]);
2008 assert_eq!(later.card.agenda.as_ref().unwrap().events[0].title, "Choir");
2009 let after = make("2026-10-05T21:00[Europe/Paris]", &Overrides::default(), true, "en", &[]);
2010 assert_eq!(after.card.agenda.as_ref().unwrap().events[0].title, "Dentist");
2011 let config = config();
2013 let tr = Translator::new("en");
2014 let now = at("2026-10-05T10:00[Europe/Paris]");
2015 let blocks = blocks(&now);
2016 let overrides = Overrides::default();
2017 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2018 let far = events_of(&[("Far away", "2026-11-20T10:00[Europe/Paris]", "2026-11-20T11:00[Europe/Paris]", false, None, false)]);
2019 assert!(snapshot(&moment, None, None, Some(&far), &[]).agenda.unwrap().events.is_empty());
2020 let none = AgendaInput { events: Vec::new(), calendars: false, read_on: now.date(), aside: BTreeSet::new() };
2022 assert!(snapshot(&moment, None, None, Some(&none), &[]).agenda.is_none());
2023 let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "fr", &[]);
2025 let agenda = fr.card.agenda.as_ref().unwrap();
2026 assert_eq!(names(&agenda.days[0])[0].1, "Aujourd’hui, lun. 5 oct.");
2027 assert_eq!(names(&agenda.days[3])[1].1, "Demain, jeu. 8 oct.");
2028 assert_eq!(names(&agenda.days[7])[0].1, "lun. 12 oct.");
2029 assert_eq!(agenda.events.iter().find(|e| e.title == "Trip to the sea").unwrap().time, "Jusqu’au ven. 16 oct.");
2030 assert_eq!(agenda.events.iter().find(|e| e.title == "Night train").unwrap().time, "22:30 – mer. 21 oct. 06:45");
2031 assert_eq!(fr.card.words.mail, "Courrier");
2032 }
2033
2034 #[test]
2035 fn no_count_at_all() {
2036 let now = at("2026-10-05T10:00[Europe/Paris]");
2037 let config = config();
2038 let overrides = Overrides::default();
2039 let blocks = blocks(&now);
2040 let digits = regex_free_digits;
2042 for language in ["en", "fr"] {
2043 for n in [0, 1, 7, 12, 30] {
2044 for details in [true, false] {
2045 let tr = Translator::new(language);
2046 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details, dnd: None };
2047 let mut input = porch_input(&now);
2048 input.items = (0..n).map(|i| message(&format!("Someone <s{i}@example.org>"), "Hello", Lane::People)).collect();
2049 input.senders = Senders { safe: sioul_core::porch::SenderList::parse("*@example.org\n"), ..Senders::default() };
2050 let card = snapshot(&moment, Some(&input), Some(&plan_input(&now)), Some(&some_events()), &[]);
2051 for frame in &card.frames {
2052 let shown = words(&card, frame);
2053 assert!(!digits(&shown), "a number on the card: {shown}");
2054 for count in ["letters came", "letter came", "lettres sont arrivées", "lettre est arrivée", "Many", "Beaucoup", "Seven", "Sept", "Twelve", "Douze"] {
2056 assert!(!shown.contains(count), "a count on the card: {shown}");
2057 }
2058 }
2059 }
2060 }
2061 }
2062 }
2063
2064 fn regex_free_digits(text: &str) -> bool {
2066 let mut rest = String::new();
2067 let chars: Vec<char> = text.chars().collect();
2068 let mut i = 0;
2069 while i < chars.len() {
2070 if chars[i].is_ascii_digit() && chars.get(i + 1).is_some_and(char::is_ascii_digit) && chars.get(i + 2) == Some(&':') && chars.get(i + 3).is_some_and(char::is_ascii_digit) && chars.get(i + 4).is_some_and(char::is_ascii_digit) {
2072 i += 5;
2073 continue;
2074 }
2075 rest.push(chars[i]);
2076 i += 1;
2077 }
2078 let months = [
2080 "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre", "Jan", "Feb", "Mar",
2081 "Apr", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "janv.", "févr.", "avr.", "juil.", "sept.", "oct.", "nov.", "déc.",
2082 ];
2083 let mut cleaned = rest.clone();
2084 for month in months {
2085 for day in (1..=31).rev() {
2086 cleaned = cleaned.replace(&format!("{day} {month}"), month).replace(&format!("{day}\u{a0}{month}"), month).replace(&format!("{day}er {month}"), month);
2087 }
2088 }
2089 cleaned.chars().any(|c| c.is_ascii_digit())
2090 }
2091
2092 fn dose(name: &str, time: &str, when: &str, known_until: i64) -> Dose {
2093 let due = at(when).timestamp().as_second();
2094 Dose { name: name.into(), time: time.into(), at: due, until: due + 12 * 3600, known_until }
2095 }
2096
2097 #[test]
2098 fn doses_say_what_is_known_and_no_more() {
2099 let due = at("2026-10-05T09:45[Europe/Paris]").timestamp().as_second();
2100 let doses = [dose("Magnesium · 300 mg", "09:45", "2026-10-05T09:45[Europe/Paris]", due + 20 * 60), dose("Levothyroxine · 75 µg", "06:00", "2026-10-06T06:00[Europe/Paris]", 0)];
2102 let made = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "en", &doses);
2103 let lines = &made.card.doses;
2104 assert_eq!(lines[0].line, "Magnesium · 300 mg, 09:45");
2105 assert_eq!(lines[0].check, "Magnesium · 300 mg, 09:45: check before taking it.");
2106 assert_eq!((lines[0].from, lines[0].known_until), (due * 1000, (due + 20 * 60) * 1000));
2107 assert_eq!(lines[1].known_until, 0);
2109 for line in lines {
2110 for word in [&line.line, &line.check] {
2111 let lower = word.to_lowercase();
2112 for never in ["missed", "late", "not taken", "overdue", "forgot"] {
2113 assert!(!lower.contains(never), "{word}");
2114 }
2115 }
2116 }
2117 assert!(made.at("2026-10-06T05:00[Europe/Paris]").doses);
2119 let mut silent = config();
2121 silent.reminders.doses_in_sleep = false;
2122 let now = at("2026-10-05T10:00[Europe/Paris]");
2123 let tr = Translator::new("en");
2124 let blocks = blocks(&now);
2125 let overrides = Overrides::default();
2126 let moment = Moment { now: &now, config: &silent, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2127 let card = snapshot(&moment, None, None, None, &doses);
2128 let night = card.frames.iter().find(|f| f.kind == "sleep").unwrap();
2129 assert!(!night.doses, "doses during sleep: stay silent");
2130 assert!(night.codes, "codes you asked for come at once while you sleep (Q6; before, they waited on the Porch)");
2131 let mut grid = config();
2133 grid.attention = toml::from_str::<Config>("[attention]\ndoses = [\"sleep:later\"]\ncodes = [\"sleep:never\"]\n").unwrap().attention;
2134 let card = snapshot(&Moment { config: &grid, ..moment }, None, None, None, &doses);
2135 let night = card.frames.iter().find(|f| f.kind == "sleep").unwrap();
2136 assert!(!night.doses && !night.codes);
2137 let fr = make("2026-10-05T10:00[Europe/Paris]", &Overrides::default(), true, "fr", &doses);
2139 assert_eq!(fr.card.doses[0].check, "Magnesium · 300 mg, 09:45\u{202f}: vérifiez avant de la prendre.");
2140 }
2141
2142 #[test]
2143 fn its_age_is_said_once_it_is_old() {
2144 let made = make("2026-10-05T14:05[Europe/Paris]", &Overrides::default(), true, "en", &[]);
2145 let card = &made.card;
2146 assert_eq!(card.stale_after, card.made + 15 * 60 * 1000);
2147 assert_eq!(card.stale[0].text, "at 14:05");
2148 assert_eq!(card.stale[0].until, at("2026-10-06T00:00[Europe/Paris]").timestamp().as_millisecond());
2149 assert_eq!(card.stale[1].text, "yesterday at 14:05");
2150 assert_eq!(card.stale[1].until, at("2026-10-07T00:00[Europe/Paris]").timestamp().as_millisecond());
2151 assert_eq!((card.stale[2].until, card.stale[2].text.as_str()), (0, "Mon 5 Oct 14:05"));
2152 assert_eq!(card.beyond, "Open Sioul to bring this card up to date.");
2153 let fr = make("2026-10-05T14:05[Europe/Paris]", &Overrides::default(), true, "fr", &[]);
2154 assert_eq!((fr.card.stale[0].text.as_str(), fr.card.stale[1].text.as_str()), ("à 14:05", "hier à 14:05"));
2155 let config = config();
2157 let tr = Translator::new("en");
2158 let overrides = Overrides::default();
2159 let first = at("2026-10-05T14:05[Europe/Paris]");
2160 let (porch, plan) = (porch_input(&first), plan_input(&first));
2161 let card_at = |now: &Zoned| {
2162 let blocks = blocks(now);
2163 let moment = Moment { now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2164 snapshot(&moment, Some(&porch), Some(&plan), Some(&some_events()), &[])
2165 };
2166 let (before, after) = (card_at(&first), card_at(&at("2026-10-05T14:06[Europe/Paris]")));
2167 assert_ne!(before.made, after.made);
2168 assert_eq!(content(&before), content(&after));
2169 let mut more = porch_input(&first);
2171 more.items.push(message("Ana <ana@example.org>", "Tea?", Lane::People));
2172 let blocks = blocks(&first);
2173 let moment = Moment { now: &first, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2174 assert_ne!(content(&before), content(&snapshot(&moment, Some(&more), Some(&plan), Some(&some_events()), &[])));
2175 }
2176
2177 #[test]
2178 fn not_set_up_yet() {
2179 let now = at("2026-10-05T10:00[Europe/Paris]");
2180 let config = Config { accounts: Vec::new(), ..config() };
2181 let tr = Translator::new("en");
2182 let blocks = blocks(&now);
2183 let overrides = Overrides::default();
2184 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2185 let card = snapshot(&moment, Some(&porch_input(&now)), None, None, &[]);
2186 assert_eq!(card.frames[0].porch.as_ref().unwrap().line, "Sioul is not set up yet.");
2187 assert!(card.frames.iter().filter(|f| f.kind == "sleep").all(|f| f.porch.is_none()));
2189 }
2190
2191 #[test]
2198 fn the_file_java_reads() {
2199 let doses = [dose("Magnesium · 300 mg", "09:45", "2026-10-08T09:45[Europe/Paris]", 0)];
2200 let made = full("2026-10-08T09:20[Europe/Paris]", true, "en", &doses);
2201 let json = serde_json::to_value(&made).unwrap();
2202 for (variable, language) in [("SIOUL_CARD_SAMPLE", "en"), ("SIOUL_CARD_SAMPLE_FR", "fr")] {
2203 if let Some(path) = std::env::var_os(variable) {
2204 let card = full("2026-10-08T09:20[Europe/Paris]", true, language, &doses);
2205 std::fs::write(path, serde_json::to_string_pretty(&card).unwrap()).unwrap();
2206 }
2207 }
2208 for key in ["made", "stale_after"] {
2209 assert!(json[key].is_i64(), "{key}");
2210 }
2211 assert!(json["details"].is_boolean() && json["beyond"].is_string());
2212 assert!(json["stale"][0]["until"].is_i64() && json["stale"][0]["text"].is_string());
2213 assert!(json["frames"].is_null());
2215 let frame = &json["times"][0];
2216 for key in ["from", "until"] {
2217 assert!(frame[key].is_i64(), "{key}");
2218 }
2219 assert!(frame["status"].is_string() && frame["codes"].is_boolean() && frame["doses"].is_boolean() && frame["events"].is_boolean());
2220 assert!(frame["porch"]["line"].is_string() && frame["porch"]["mail"][0].is_u64());
2221 for key in ["title", "why", "uid"] {
2222 assert!(frame["step"][key].is_string(), "{key}");
2223 }
2224 assert!(frame["reminders"][0].is_u64() && frame["calls"][0].is_u64() && frame["overlaps"].is_boolean() && frame["stopped"].is_boolean() && frame["note"].is_string());
2225 assert!(json["v"] == 3 && json["words"]["mail"].is_string() && json["words"]["agenda"].is_string());
2226 let today = &json["today"];
2228 assert!(today["date"][0]["until"].is_i64() && today["date"][0]["text"].is_string() && today["line"].is_string());
2229 let hour = &today["weather"]["hours"][0];
2230 assert!(hour["from"].is_i64() && hour["until"].is_i64());
2231 for slot in [&hour["now"], &hour["next"][0], &hour["parts"][0]] {
2232 for key in ["label", "icon", "temperature", "rain", "words"] {
2233 assert!(slot[key].is_string(), "{key}");
2234 }
2235 }
2236 let day = &today["weather"]["days"][0];
2237 assert!(day["from"].is_i64() && day["until"].is_i64());
2238 for key in ["label", "icon", "high", "low", "rain", "words"] {
2239 assert!(day[key].is_string(), "{key}");
2240 }
2241 assert!(today["weather"]["credit"].is_string());
2242 for key in ["line", "open"] {
2244 assert!(json["reminders"][0][key].is_string(), "{key}");
2245 }
2246 assert!(json["reminders"][0]["from"].is_i64() && json["reminders"][0]["until"].is_i64());
2247 assert!(json["calls"][0].is_string() && json["stopped"].is_string());
2248 assert!(json["overlaps"][0]["line"].is_string() && json["overlaps"][0]["from"].is_i64() && json["overlaps"][0]["until"].is_i64());
2249 let mail = &json["mail"][0];
2251 for key in ["key", "sender", "subject", "text"] {
2252 assert!(mail[key].is_string(), "{key}");
2253 }
2254 assert!(mail["unread"].is_boolean() && mail["account"].is_i64());
2255 assert!(mail["when"][0]["until"].is_i64() && mail["when"][0]["text"].is_string());
2256 let agenda = &json["agenda"];
2258 assert!(agenda["empty"].is_string());
2259 assert!(agenda["days"][0]["from"].is_i64() && agenda["days"][0]["until"].is_i64() && agenda["days"][0]["label"][0]["until"].is_i64() && agenda["days"][0]["label"][0]["text"].is_string());
2260 let event = &agenda["events"][0];
2261 for key in ["key", "title", "time", "color"] {
2262 assert!(event[key].is_string(), "{key}");
2263 }
2264 assert!(event["all_day"].is_boolean() && event["start"].is_i64() && event["end"].is_i64());
2265 assert!(json["codes"][0]["line"].is_string() && json["codes"][0]["warning"].is_string() && json["codes"][0]["until"].is_i64());
2266 for key in ["from", "until", "known_until"] {
2267 assert!(json["doses"][0][key].is_i64(), "{key}");
2268 }
2269 assert!(json["doses"][0]["line"].is_string() && json["doses"][0]["check"].is_string());
2270 assert!(frame["dnd"].is_string());
2271 let night = serde_json::to_value(Made { card: made }.at("2026-10-08T23:30[Europe/Paris]")).unwrap();
2273 assert!(night["porch"].is_null() && night["step"].is_null());
2274 }
2275
2276 #[test]
2277 fn the_do_not_disturb_everywhere_says_its_line() {
2278 let now = at("2026-10-05T14:00[Europe/Paris]");
2279 let config = config();
2280 let tr = Translator::new("en");
2281 let blocks = blocks(&now);
2282 let overrides = Overrides::default();
2283 let until = at("2026-10-05T15:00[Europe/Paris]").timestamp().as_second();
2284 let line = "Do not disturb, on every device, until 15:00.";
2285 let moment = serde_json::json!({ "on": true, "why": "manual", "until": "15:00", "until_at": until, "line": line, "everywhere": true, "here": true });
2286 let dnd = Dnd::of(&moment).unwrap();
2287 let (porch, plan) = (porch_input(&now), plan_input(&now));
2288 let card_with = |dnd: Option<&Dnd>| {
2289 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd };
2290 snapshot(&moment, Some(&porch), Some(&plan), None, &[])
2291 };
2292 let made = Made { card: card_with(Some(&dnd)) };
2293 assert_eq!(made.at("2026-10-05T14:30[Europe/Paris]").dnd, line);
2295 assert_eq!(made.at("2026-10-05T14:30[Europe/Paris]").until, at("2026-10-05T15:00[Europe/Paris]").timestamp().as_millisecond());
2296 assert_eq!(made.at("2026-10-05T15:00[Europe/Paris]").dnd, "");
2297 for (on, why) in [(false, "manual"), (true, "sleep"), (true, "paused"), (true, "free-time")] {
2299 assert_eq!(Dnd::of(&serde_json::json!({ "on": on, "why": why, "line": line, "until_at": until })), None, "{on} {why}");
2300 }
2301 let focus = Dnd::of(&serde_json::json!({ "on": true, "why": "focus", "line": line, "until_at": 0 })).unwrap();
2303 assert_eq!(focus.until, None);
2304 let lasting = Dnd::of(&serde_json::json!({ "on": true, "why": "manual", "line": line, "until_at": 0 })).unwrap();
2306 let card = card_with(Some(&lasting));
2307 assert!(card.frames.iter().all(|f| (f.kind == "sleep") == f.dnd.is_empty()), "{:#?}", card.frames);
2308 let paused = Overrides { paused_since: Some(now.timestamp().as_second() - 60), ..Overrides::default() };
2310 let moment = Moment { now: &now, config: &config, overrides: &paused, blocks: &blocks, tr: &tr, details: true, dnd: Some(&lasting) };
2311 assert!(snapshot(&moment, Some(&porch), Some(&plan), None, &[]).frames.iter().all(|f| f.dnd.is_empty()));
2312 let unsaid = Dnd::of(&serde_json::json!({ "on": true, "why": "manual", "line": line })).unwrap();
2314 let card = card_with(Some(&unsaid));
2315 assert_eq!(card.frames[0].dnd, line);
2316 assert!(card.frames[1..].iter().all(|f| f.dnd.is_empty()));
2317 let mut written = serde_json::to_value(card_with(None)).unwrap();
2319 let before = written["times"].as_array().unwrap().len();
2320 let stamp = now.timestamp().as_millisecond();
2321 assert!(patch_dnd(&mut written, Some(&dnd), stamp));
2322 let frames = written["times"].as_array().unwrap().clone();
2323 assert_eq!(frames.len(), before + 1, "cut at 15:00");
2324 let shown = |when: &str| {
2325 let t = at(when).timestamp().as_millisecond();
2326 frames.iter().find(|f| f["from"].as_i64().unwrap() <= t && t < f["until"].as_i64().unwrap()).unwrap()["dnd"].clone()
2327 };
2328 assert_eq!(shown("2026-10-05T14:59[Europe/Paris]"), line);
2329 assert_eq!(shown("2026-10-05T15:01[Europe/Paris]"), "");
2330 assert!(!patch_dnd(&mut written, Some(&dnd), stamp));
2332 assert!(patch_dnd(&mut written, None, stamp));
2333 assert!(written["times"].as_array().unwrap().iter().all(|f| f["dnd"] == ""));
2334 let mut older = serde_json::json!({ "v": 2, "frames": [{ "from": 0, "until": i64::MAX, "kind": "work", "dnd": "" }] });
2336 assert!(patch_dnd(&mut older, Some(&lasting), stamp));
2337 assert_eq!(older["frames"][0]["dnd"], line);
2338 }
2339
2340 const SAVED: &str = include_str!("../../sioul-core/tests/fixtures/weather/open-meteo.json");
2342
2343 fn forecast_at(now: &Zoned) -> Forecast {
2345 forecast::parse(SAVED, now.timestamp().as_second() - 60).unwrap()
2346 }
2347
2348 fn reminder(kind: reminders::Kind, key: &str, at_: &str, until: &str, title: &str, body: &str, target: &str, work: bool) -> Reminder {
2350 Reminder { key: key.into(), kind, at: at(at_).timestamp().as_second(), until: at(until).timestamp().as_second(), title: title.into(), body: body.into(), target: target.into(), work, starts: None }
2351 }
2352
2353 fn some_reminders() -> Vec<Reminder> {
2356 use reminders::Kind;
2357 vec![
2358 reminder(Kind::Asked, "asked:form", "2026-10-08T09:00[Europe/Paris]", "2026-10-31T00:00[Europe/Paris]", "Asked for Friday 30 October", "Call the CAF about the housing aid", "sioul:task/form", true),
2359 reminder(Kind::Payment, "payment:rent", "2026-10-08T09:00[Europe/Paris]", "2026-10-10T00:00[Europe/Paris]", "Rent · €650", "Planned for Friday 9 October; the account will hold it.", "sioul:budget/home", false),
2360 reminder(Kind::Paper, "paper:passport", "2026-10-09T09:00[Europe/Paris]", "2026-11-02T00:00[Europe/Paris]", "Passport: valid until Sunday 1 November", "Renewing takes weeks (an appointment, then the making): a step now?", "sioul:paper/passport", false),
2361 reminder(Kind::Before, "before:dentist", "2026-10-08T10:45[Europe/Paris]", "2026-10-08T11:00[Europe/Paris]", "11:00 · Dentist, in 15 minutes", "", "/calendars/me/personal/event-0.ics", false),
2362 ]
2363 }
2364
2365 fn some_calls(moment: &sioul_core::attention::Now, at_: &Zoned) -> Vec<String> {
2368 use sioul_core::attention::Column;
2369 if moment.is(Column::Sleep) || moment.is(Column::Pause) {
2370 return Vec::new();
2371 }
2372 let day = if at_.date() == Date::constant(2026, 10, 8) { "While you slept" } else { "Yesterday, while you slept" };
2373 vec![format!("{day}, Marie Dupont called at 07:40.")]
2374 }
2375
2376 fn full(now: &str, details: bool, language: &str, doses: &[Dose]) -> Snapshot {
2380 let now = at(now);
2381 let config = config();
2382 let tr = Translator::new(language);
2383 let blocks = blocks(&now);
2384 let overrides = Overrides::default();
2385 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details, dnd: None };
2386 let agenda = events_of(&[
2387 ("Dentist", "2026-10-08T11:00[Europe/Paris]", "2026-10-08T12:00[Europe/Paris]", false, None, false),
2388 ("School meeting", "2026-10-08T11:30[Europe/Paris]", "2026-10-08T12:30[Europe/Paris]", false, None, false),
2389 ("Choir", "2026-10-09T19:00[Europe/Paris]", "2026-10-09T20:30[Europe/Paris]", false, None, false),
2390 ("Neighbours' party", "2026-10-09T20:00[Europe/Paris]", "2026-10-09T23:00[Europe/Paris]", false, None, false),
2391 ("Trip to the sea", "2026-10-14T00:00[Europe/Paris]", "2026-10-17T00:00[Europe/Paris]", true, None, false),
2392 ]);
2393 let reminded = some_reminders();
2394 let more = More { weather: WeatherInput { place: true, forecast: Some(forecast_at(&now)) }, reminders: &reminded, calls: Some(&some_calls), stopped: Some("Halfway through the CAF's form, page 2".into()) };
2395 snapshot_with(&moment, Some(&porch_input(&now)), Some(&plan_input(&now)), Some(&agenda), doses, &more)
2396 }
2397
2398 #[test]
2399 fn today_comes_first_hour_by_hour() {
2400 let card = full("2026-10-08T09:20[Europe/Paris]", true, "en", &[]);
2401 let ms = |text: &str| at(text).timestamp().as_millisecond();
2402 let dates: Vec<(i64, &str)> = card.today.date.iter().map(|l| (l.until, l.text.as_str())).collect();
2404 assert_eq!(dates, [(ms("2026-10-09T00:00[Europe/Paris]"), "Thursday 8 October"), (ms("2026-10-10T00:00[Europe/Paris]"), "Friday 9 October")]);
2405 assert_eq!(card.today.line, "");
2406 let weather = card.today.weather.as_ref().unwrap();
2407 assert_eq!(weather.credit, "Weather: Open-Meteo.com");
2408 assert_eq!(weather.hours.first().unwrap().from, ms("2026-10-08T09:00[Europe/Paris]"));
2410 assert_eq!(weather.hours.last().unwrap().until, card.frames.last().unwrap().until);
2411 assert!(weather.hours.windows(2).all(|w| w[0].until == w[1].from), "no hole between the hours");
2412 assert!(weather.hours.iter().all(|h| h.next.len() == 4 && h.parts.len() == 4), "{:#?}", weather.hours.iter().map(|h| (h.from, h.next.len(), h.parts.len())).collect::<Vec<_>>());
2415 let hour = |text: &str| weather.hours.iter().find(|h| h.from <= ms(text) && ms(text) < h.until).unwrap();
2416 let morning = hour("2026-10-08T09:20[Europe/Paris]");
2417 assert_eq!((morning.now.temperature.as_str(), morning.now.rain.as_str()), ("16°", "98 %"));
2418 assert_eq!(morning.next.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["10:00", "11:00", "12:00", "13:00"]);
2419 assert_eq!(morning.parts.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["Afternoon", "Evening", "Night", "Morning"]);
2420 let afternoon = hour("2026-10-08T14:30[Europe/Paris]");
2421 assert_eq!((afternoon.now.temperature.as_str(), afternoon.next[0].label.as_str()), ("14°", "15:00"));
2422 assert_eq!(afternoon.parts.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["Evening", "Night", "Morning", "Afternoon"]);
2424 let late = hour("2026-10-09T22:10[Europe/Paris]");
2425 assert_eq!(late.next.iter().map(|s| s.label.as_str()).collect::<Vec<_>>(), ["23:00", "00:00", "01:00", "02:00"]);
2426 assert_eq!(weather.days.len(), 9);
2428 assert_eq!((weather.days[1].from, weather.days[1].until), (ms("2026-10-09T00:00[Europe/Paris]"), ms("2026-10-10T00:00[Europe/Paris]")));
2429 assert_eq!((weather.days[1].day.label.as_str(), weather.days[1].day.high.as_str(), weather.days[1].day.low.as_str()), ("Fri", "17°", "13°"));
2430 let fr = full("2026-10-08T09:20[Europe/Paris]", true, "fr", &[]);
2432 assert_eq!(fr.today.date[0].text, "Jeudi 8 octobre");
2433 assert_eq!(fr.today.weather.as_ref().unwrap().credit, "Météo\u{202f}: Open-Meteo.com");
2434 let plain = full("2026-10-08T09:20[Europe/Paris]", false, "en", &[]);
2436 assert_eq!(plain.today, card.today);
2437 }
2438
2439 #[test]
2440 fn no_place_no_weather() {
2441 let now = at("2026-10-08T09:20[Europe/Paris]");
2442 let config = config();
2443 let tr = Translator::new("en");
2444 let blocks = blocks(&now);
2445 let overrides = Overrides::default();
2446 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2447 let with = |weather: WeatherInput| snapshot_with(&moment, None, None, None, &[], &More { weather, ..More::default() }).today;
2448 let none = with(WeatherInput::default());
2450 assert_eq!((none.weather.is_none(), none.line.as_str()), (true, "Choose a place for the weather in Sioul: tap the weather in its status line."));
2451 assert_eq!(none.date[0].text, "Thursday 8 October");
2452 for forecast in [None, Some(forecast::parse(SAVED, now.timestamp().as_second() - 2 * 86_400).unwrap())] {
2454 let shown = with(WeatherInput { place: true, forecast });
2455 assert_eq!((shown.weather.is_none(), shown.line.as_str()), (true, "No forecast yet."));
2456 }
2457 let fr = Translator::new("fr");
2459 let moment = Moment { tr: &fr, ..moment };
2460 let said = snapshot_with(&moment, None, None, None, &[], &More::default()).today.line;
2461 assert_eq!(said, "Choisissez un lieu pour la météo dans Sioul\u{202f}: touchez la météo dans sa ligne d’état.");
2462 }
2463
2464 #[test]
2465 fn the_porch_lines_but_no_mail() {
2466 let card = full("2026-10-08T09:20[Europe/Paris]", true, "en", &[dose("Magnesium · 300 mg", "09:45", "2026-10-08T09:45[Europe/Paris]", 0)]);
2467 let made = Made { card };
2468 let card = &made.card;
2469 let lines: Vec<&str> = card.reminders.iter().map(|r| r.line.as_str()).collect();
2471 assert_eq!(lines, ["Call the CAF about the housing aid · Asked for Friday 30 October", "Rent · €650 · Planned for Friday 9 October; the account will hold it.", "Passport: valid until Sunday 1 November"]);
2472 assert_eq!((card.reminders[0].open.as_str(), card.reminders[2].from), ("sioul:task/form", at("2026-10-09T09:00[Europe/Paris]").timestamp().as_millisecond()));
2473 assert_eq!(made.at("2026-10-08T10:00[Europe/Paris]").reminders, [0, 1, 2]);
2476 assert_eq!(made.at("2026-10-08T17:30[Europe/Paris]").reminders, [1, 2]);
2477 assert!(made.at("2026-10-08T23:30[Europe/Paris]").reminders.is_empty());
2478 let work = made.at("2026-10-08T10:00[Europe/Paris]");
2480 assert_eq!(card.calls[work.calls[0]], "While you slept, Marie Dupont called at 07:40.");
2481 assert_eq!(card.calls[made.at("2026-10-09T10:00[Europe/Paris]").calls[0]], "Yesterday, while you slept, Marie Dupont called at 07:40.");
2482 assert!(made.at("2026-10-08T23:30[Europe/Paris]").calls.is_empty());
2483 let overlaps: Vec<(&str, i64)> = card.overlaps.iter().map(|o| (o.line.as_str(), o.from)).collect();
2485 assert_eq!(
2486 overlaps,
2487 [
2488 ("Today, two events at once: Dentist and School meeting.", at("2026-10-08T00:00[Europe/Paris]").timestamp().as_millisecond()),
2489 ("Today, two events at once: Choir and Neighbours' party.", at("2026-10-09T00:00[Europe/Paris]").timestamp().as_millisecond()),
2490 ]
2491 );
2492 assert_eq!(card.overlaps[0].until, at("2026-10-08T12:30[Europe/Paris]").timestamp().as_millisecond());
2493 assert!(work.overlaps && !made.at("2026-10-08T23:30[Europe/Paris]").overlaps);
2494 assert_eq!(card.stopped, "Where you stopped: Halfway through the CAF's form, page 2");
2496 assert!(work.stopped && work.step.is_some());
2497 assert!(!made.at("2026-10-08T17:30[Europe/Paris]").stopped);
2498 for frame in &card.frames {
2501 let shown = full_card_words(card, frame);
2502 for mail in ["Marie Dupont <", "Dinner on Saturday", "The photos", "Paul", "Léa", "A question"] {
2503 assert!(!shown.contains(mail), "{mail} on the full card: {shown}");
2504 }
2505 }
2506 assert!(!card.mail.is_empty(), "the mail card's messages are still written");
2507 let plain = full("2026-10-08T09:20[Europe/Paris]", false, "en", &[]);
2509 assert!(plain.reminders.iter().all(|r| r.line == "A reminder waits in Sioul."));
2510 assert_eq!(plain.calls, ["A call was declined: it is on the Porch."]);
2511 assert_eq!(plain.overlaps[0].line, "Today, two events at once.");
2512 assert_eq!(plain.stopped, "Your line on where you stopped waits in Sioul.");
2513 let all = serde_json::to_string(&serde_json::json!([plain.reminders, plain.calls, plain.overlaps, plain.stopped])).unwrap();
2514 for private in ["CAF", "Rent", "Passport", "Marie", "Dentist", "Choir", "Halfway"] {
2515 assert!(!all.contains(private), "{private} shown with the details off: {all}");
2516 }
2517 let fr = full("2026-10-08T09:20[Europe/Paris]", true, "fr", &[]);
2519 assert_eq!(fr.overlaps[0].line, "Aujourd’hui, deux événements en même temps\u{202f}: Dentist et School meeting.");
2520 assert_eq!(fr.stopped, "Où vous en étiez\u{202f}: Halfway through the CAF's form, page 2");
2521 let now = at("2026-10-08T10:00[Europe/Paris]");
2523 let config = config();
2524 let tr = Translator::new("en");
2525 let blocks = blocks(&now);
2526 let paused = Overrides { paused_since: Some(now.timestamp().as_second() - 60), ..Overrides::default() };
2527 let moment = Moment { now: &now, config: &config, overrides: &paused, blocks: &blocks, tr: &tr, details: true, dnd: None };
2528 let reminded = some_reminders();
2529 let card = snapshot_with(&moment, None, None, None, &[], &More { reminders: &reminded, calls: Some(&some_calls), stopped: Some("Halfway".into()), ..More::default() });
2530 assert!(card.frames.iter().all(|f| f.reminders.is_empty() && f.calls.is_empty() && !f.overlaps && !f.stopped));
2531 }
2532
2533 fn full_card_words(card: &Snapshot, frame: &Frame) -> String {
2536 let mut out: Vec<String> = card.today.date.iter().map(|l| l.text.clone()).collect();
2537 out.extend([card.today.line.clone(), frame.status.clone(), frame.dnd.clone(), frame.note.clone(), card.stopped.clone()]);
2538 out.extend(card.codes.iter().flat_map(|c| [c.line.clone(), c.warning.clone()]));
2539 out.extend(card.doses.iter().flat_map(|d| [d.line.clone(), d.check.clone()]));
2540 out.extend(frame.reminders.iter().map(|at| card.reminders[*at].line.clone()));
2541 out.extend(frame.calls.iter().map(|at| card.calls[*at].clone()));
2542 out.extend(card.overlaps.iter().map(|o| o.line.clone()));
2543 out.extend(frame.step.iter().map(|s| s.title.clone()));
2544 out.join("\n")
2545 }
2546
2547 #[test]
2548 fn a_reminders_tap_opens_what_it_is_about() {
2549 let now = 1_791_300_000_000;
2550 let none = String::new;
2551 let tap_on = |key: &str| tap(&format!(r#"{{"open":"reminder","key":"{key}","at":{now}}}"#), now);
2552 assert_eq!(tap_on("sioul:task/form"), Some(("task", "sioul:task/form".to_string(), "form".to_string())));
2553 assert_eq!(tap_on("sioul:budget/home"), Some(("budget", "sioul:budget/home".to_string(), none())));
2554 assert_eq!(tap_on("sioul:paper/passport"), Some(("paper", "sioul:paper/passport".to_string(), "passport".to_string())));
2555 assert_eq!(tap_on("sioul:contract/box"), Some(("contract", "sioul:contract/box".to_string(), "box".to_string())));
2556 assert_eq!(tap_on(""), Some(("porch", none(), none())));
2557 }
2558
2559 #[test]
2560 fn a_tap_is_taken_once_and_not_late() {
2561 let now = 1_791_300_000_000;
2562 let none = String::new;
2563 assert_eq!(tap(&format!(r#"{{"open":"porch","at":{now}}}"#), now + 1000), Some(("porch", none(), none())));
2564 assert_eq!(tap(&format!(r#"{{"open":"now","uid":"a b/c","at":{now}}}"#), now), Some(("now", "sioul:task/a%20b%2Fc".to_string(), none())));
2565 assert_eq!(tap(&format!(r#"{{"open":"now","uid":"","at":{now}}}"#), now), Some(("now", none(), none())));
2566 let file = "/mail/me/INBOX/new/1759600000.a.sioul";
2568 assert_eq!(tap(&format!(r#"{{"open":"mail","key":"{file}","at":{now}}}"#), now), Some(("card-mail", none(), file.to_string())));
2569 assert_eq!(tap(&format!(r#"{{"open":"event","key":"/calendars/me/personal/a.ics","at":{now}}}"#), now), Some(("event", none(), "/calendars/me/personal/a.ics".to_string())));
2570 assert_eq!(tap(&format!(r#"{{"open":"agenda","at":{now}}}"#), now), Some(("agenda", none(), none())));
2571 assert_eq!(tap(&format!(r#"{{"open":"mail","at":{now}}}"#), now), Some(("porch", none(), none())));
2573 assert_eq!(tap(&format!(r#"{{"open":"event","key":"","at":{now}}}"#), now), Some(("agenda", none(), none())));
2574 assert_eq!(tap(&format!(r#"{{"open":"porch","at":{now}}}"#), now + 6 * 60 * 1000), None);
2576 assert_eq!(tap(r#"{"open":"elsewhere","at":0}"#, 0), None);
2577 assert_eq!(tap("not json", now), None);
2578 }
2579
2580 #[test]
2584 fn patch_mail_makes_the_porch_again_and_nothing_else() {
2585 let now = at("2026-10-05T10:00[Europe/Paris]");
2586 let config = config();
2587 let tr = Translator::new("en");
2588 let blocks = blocks(&now);
2589 let overrides = Overrides::default();
2590 let moment = Moment { now: &now, config: &config, overrides: &overrides, blocks: &blocks, tr: &tr, details: true, dnd: None };
2591 let (plan, agenda) = (plan_input(&now), some_events());
2592 let before = porch_input(&now);
2593 let written = serde_json::to_value(snapshot(&moment, Some(&before), Some(&plan), Some(&agenda), &[])).unwrap();
2594 let mut after = porch_input(&now);
2596 after.items.retain(|m| m.card.from_address.as_deref() != Some("tom@example.org") && m.code.is_none());
2597 after.items.insert(0, sent_at("Paul <paul@example.org>", "Tickets for Sunday", now.timestamp().as_second() - 60, false));
2598 let mut card = written.clone();
2599 assert!(patch_mail(&mut card, &moment, &after));
2600 let whole = serde_json::to_value(snapshot(&moment, Some(&after), Some(&plan), Some(&agenda), &[])).unwrap();
2601 assert_eq!(card["mail"], whole["mail"]);
2602 assert_eq!(card["codes"], whole["codes"]);
2603 assert_eq!(card["codes"], serde_json::json!([]), "the code gone with its message");
2604 let (frames, made, was) = (card["times"].as_array().unwrap(), whole["times"].as_array().unwrap(), written["times"].as_array().unwrap());
2605 assert_eq!(frames.len(), made.len());
2606 for ((frame, made), was) in frames.iter().zip(made).zip(was) {
2607 assert_eq!(frame["porch"], made["porch"], "the frame from {}", frame["from"]);
2608 for key in ["from", "until", "status", "step", "note", "events", "codes", "doses"] {
2610 assert_eq!(frame[key], was[key], "{key}");
2611 }
2612 if was["porch"].is_null() {
2613 assert!(frame["porch"].is_null(), "asleep, no Porch made");
2614 }
2615 }
2616 let senders: Vec<&str> = card["mail"].as_array().unwrap().iter().map(|m| m["sender"].as_str().unwrap()).collect();
2617 assert!(senders.contains(&"Paul") && !senders.contains(&"Tom"), "{senders:?}");
2618 for key in ["today", "agenda", "words", "made", "stale_after", "details"] {
2619 assert_eq!(card[key], written[key], "{key}");
2620 }
2621 assert!(!patch_mail(&mut card, &moment, &after));
2623 let mut older = written.clone();
2625 older["v"] = serde_json::json!(2);
2626 let kept = older.clone();
2627 assert!(!patch_mail(&mut older, &moment, &after));
2628 assert_eq!(older, kept);
2629 }
2630}