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