Skip to main content

sioul_core/
reminders.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Reminders before dates (docs/reminders.md): remembering "on the 30th" is
5//! what fails most, in ADHD and in autism alike, and reminders work where
6//! memory is the bottleneck (Landsiedel et al. 2017; Altgassen et al. 2014;
7//! Jamieson et al. 2014). One quiet notification per date, never repeated:
8//! - an event, a quarter of an hour before it (yours to set, and each event's
9//!   own: `X-SIOUL-REMIND`), counted before its margin (getting ready,
10//!   getting there); at the end of the working day before; and at the alarms
11//!   it carries, an alarm and Sioul's reminder within minutes told once;
12//! - a date asked, a few working days before, when work starts;
13//! - a wait after a step done (an answer due), once it is over;
14//! - a payment planned, a few working days before;
15//! - a paper to renew (a passport, an identity card, the health cover), when renewing starts;
16//! - a contract that renews each year, two weeks before the last day to stop it;
17//! - from the bank's movements: a payment that did not pass, once; the day the
18//!   account would not hold a payment, five working days before.
19//!
20//! What is created after its reminder's time is not reminded: you just saw it.
21//! A reminder still makes sense until what it is about begins; a computer
22//! asleep at the time reminds on waking, if it is not too late. Work waits
23//! while work rests; your own things, events and payments do not. While you
24//! sleep nothing is told: every reminder waits for waking (docs/health.md,
25//! "Do not disturb"), and comes then if it still makes sense; but an event's
26//! own reminders come when the event falls in that sleep (a choice you
27//! made), and in a pause when it falls in the pause (docs/pauses.md). These
28//! are the usual values of the matrix of what reaches you (`attention`), which
29//! you may change kind by kind and time by time (`Holds`). Payments that leave by
30//! themselves (presets) are not reminded: the money watch tells when the
31//! account will not hold them, which a reminder alone cannot (Medina 2021).
32
33use crate::agenda::Occurrence;
34use crate::attention::{self, Attention, Level};
35use crate::budget::Ledger;
36use crate::config::{Config, TimeOff};
37use crate::window::AdminWindow;
38use crate::i18n::Translator;
39use crate::tasks::{Status, Task};
40use jiff::civil::Date;
41use jiff::tz::TimeZone;
42use jiff::{Span, Zoned};
43use serde::Serialize;
44use std::io::Write;
45use std::path::{Path, PathBuf};
46
47/// Sioul's own property for an event's reminder: absent, as usual; `NONE`,
48/// not this one; `15`, so many minutes before (before its margin). Not a
49/// VALARM: your other calendar apps would ring it too, and a VALARM cannot
50/// say "not this one" (docs/reminders.md).
51pub const REMIND: &str = "X-SIOUL-REMIND";
52
53/// The usual reminder before an event, as Settings offers it, in minutes; 0 for none.
54pub const LEADS: [u32; 7] = [0, 5, 10, 15, 30, 60, 120];
55
56/// An alarm the event carries and Sioul's reminder this close are told once, at the earlier.
57const SAME_MOMENT: i64 = 5 * 60;
58
59/// What an event says of its reminder before it (`REMIND`).
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Remind {
62    /// The usual time (Settings ▸ Reminders and notifications).
63    Usual,
64    /// Not this one.
65    Never,
66    /// So many minutes before.
67    Minutes(u32),
68}
69
70impl Remind {
71    /// As the property says it ("", "NONE", "15"); "0" is none, a word not
72    /// understood is as usual, a time at most a day.
73    pub fn read(value: &str) -> Remind {
74        let value = value.trim();
75        if value.is_empty() {
76            return Remind::Usual;
77        }
78        if value.eq_ignore_ascii_case("none") || value.eq_ignore_ascii_case("off") {
79            return Remind::Never;
80        }
81        match value.parse::<u32>() {
82            Ok(0) => Remind::Never,
83            Ok(minutes) => Remind::Minutes(minutes.min(24 * 60)),
84            Err(_) => Remind::Usual,
85        }
86    }
87
88    /// As the property writes it; none as usual.
89    pub fn value(self) -> Option<String> {
90        match self {
91            Remind::Usual => None,
92            Remind::Never => Some("NONE".to_string()),
93            Remind::Minutes(minutes) => Some(minutes.to_string()),
94        }
95    }
96
97    /// Minutes before the event and its margin, `usual` when it says nothing; 0 for none.
98    pub fn minutes(self, usual: u32) -> u32 {
99        match self {
100            Remind::Usual => usual,
101            Remind::Never => 0,
102            Remind::Minutes(minutes) => minutes,
103        }
104    }
105}
106
107/// A reminder's time before, in words: "15 minutes", "1 hour", "none".
108pub fn lead_text(tr: &Translator, minutes: u32) -> String {
109    let mut args = crate::i18n::args();
110    args.set("minutes", minutes);
111    tr.text("remind-lead", Some(&args))
112}
113
114/// The time left, in words: "in 15 minutes", "in 1 hour", "in 1 h 30", "now".
115fn in_text(tr: &Translator, seconds: i64) -> String {
116    let minutes = (seconds.max(0) + 30) / 60;
117    let mut args = crate::i18n::args();
118    let id = if minutes < 1 {
119        "reminder-in-now"
120    } else if minutes < 60 {
121        args.set("minutes", minutes);
122        "reminder-in-minutes"
123    } else if minutes % 60 == 0 {
124        args.set("hours", minutes / 60);
125        "reminder-in-hours"
126    } else {
127        args.set("hours", minutes / 60);
128        args.set("minutes", format!("{:02}", minutes % 60));
129        "reminder-in-hours-minutes"
130    };
131    tr.text(id, Some(&args))
132}
133
134/// Whether you declined the event: one of your addresses among its guests, "declined".
135fn declined(event: &Occurrence, own: &[String]) -> bool {
136    event.attendees.iter().any(|a| a.answer == "declined" && own.iter().any(|o| o.eq_ignore_ascii_case(a.address.trim())))
137}
138
139/// What a reminder is about.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
141#[serde(rename_all = "lowercase")]
142pub enum Kind {
143    /// An event, the working day before.
144    Event,
145    /// An event, so many minutes before it and its margin.
146    Before,
147    /// An alarm the event carries.
148    Alarm,
149    /// A date asked.
150    Asked,
151    /// A wait over.
152    Wait,
153    /// A payment planned.
154    Payment,
155    /// A paper to renew.
156    Paper,
157    /// A contract renewing.
158    Contract,
159    /// The money watch: a payment missed, a balance short.
160    Money,
161}
162
163/// One reminder.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub struct Reminder {
166    /// Unique to the thing and its date: told once.
167    pub key: String,
168    pub kind: Kind,
169    /// When it is due, Unix seconds.
170    pub at: i64,
171    /// Until when it still makes sense: the event begins, the day asked ends.
172    pub until: i64,
173    pub title: String,
174    pub body: String,
175    /// What it is about: `sioul:task/<UID>`, an event's file.
176    pub target: String,
177    /// Work: it waits while work rests.
178    pub work: bool,
179    /// An event's own reminder (its alarm, Sioul's before it): when the event starts, Unix seconds.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub starts: Option<i64>,
182}
183
184/// What waits now.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum Wait {
187    Nothing,
188    /// Work rests: work's reminders wait for it.
189    Work,
190    /// You sleep, from `from` (winding down, a nap) to `until` (waking):
191    /// every reminder waits for waking, but an event's own when the event
192    /// falls in this sleep: you chose it (an event at 03:00).
193    Sleep { from: i64, until: i64 },
194    /// The pause, its end unknown (docs/pauses.md): an event's own reminders
195    /// come, the event falling in it as far as anyone knows (it has not
196    /// begun); everything else waits for your return.
197    Paused,
198    /// Free time until `until`: an event's own reminders come when the event
199    /// falls in it; everything else waits for its end (docs/pauses.md).
200    Free { until: i64 },
201}
202
203/// What holds reminders now: the time (`Wait`), and what the matrix of what
204/// reaches you says then of each kind of reminder (`attention`): an event's
205/// alarms, Sioul's reminder before an event, the working day before, the
206/// other dates.
207#[derive(Debug, Clone, PartialEq)]
208pub struct Holds {
209    pub wait: Wait,
210    pub attention: Attention,
211    /// The moment as the matrix reads it, its layers read from the files
212    /// (today's slots of time for you, do-not-disturb's switch and focus);
213    /// its span the time now, from when to when (Unix seconds), for "if its
214    /// event falls then": a sleep's block, Free time, the hours until they change.
215    pub now: attention::Now,
216    /// When the time now ends, when known: what it holds is asked about again then.
217    pub until: Option<i64>,
218}
219
220impl Holds {
221    /// The usual matrix, at a time: what Sioul does unless you change it.
222    pub fn usual(wait: Wait) -> Holds {
223        let all = (i64::MIN, i64::MAX);
224        let (column, span, until) = match wait {
225            Wait::Nothing => (attention::Column::Work, all, None),
226            Wait::Work => (attention::Column::Leisure, all, None),
227            Wait::Sleep { from, until } => (attention::Column::Sleep, (from, until), Some(until)),
228            Wait::Paused => (attention::Column::Pause, all, None),
229            Wait::Free { until } => (attention::Column::Free, (i64::MIN, until), Some(until)),
230        };
231        let mut now = attention::Now::time(column);
232        now.span = span;
233        Holds { wait, attention: Attention::usual(), now, until }
234    }
235
236    /// As the configuration says, at the time `mode` (Health's `blocks` for
237    /// sleep's span), today's slots of time for you and do-not-disturb's
238    /// switch and focus read from the files.
239    pub fn of(config: &Config, mode: &crate::quiet::Mode, blocks: &crate::quiet::Blocks, stamp: i64) -> Holds {
240        let wait = wait_of(mode, blocks, stamp);
241        let ends = mode.until.as_ref().map(|u| u.timestamp().as_second());
242        let (span, until) = match wait {
243            Wait::Sleep { from, until } => ((from, until), Some(until)),
244            Wait::Paused => ((i64::MIN, i64::MAX), None),
245            _ => ((i64::MIN, ends.unwrap_or(i64::MAX)), ends),
246        };
247        let mut now = attention::Now::of(mode).layers(attention::Slots::load(&attention::Slots::default_path()).holds(stamp), attention::dnd_from_files(config, stamp));
248        now.span = span;
249        Holds { wait, attention: Attention::of(config), now, until }
250    }
251
252    /// What the matrix says now of a reminder of this kind.
253    pub fn level(&self, kind: Kind) -> Level {
254        self.attention.level(attention::Row::Own(row_of(kind)), &self.now)
255    }
256}
257
258/// A reminder's row in the matrix.
259fn row_of(kind: Kind) -> attention::Kind {
260    match kind {
261        Kind::Alarm => attention::Kind::Alarms,
262        Kind::Before => attention::Kind::Before,
263        Kind::Event => attention::Kind::DayBefore,
264        _ => attention::Kind::Dates,
265    }
266}
267
268impl Reminder {
269    /// Whether to tell it now, the matrix as usual (`Holds::usual`).
270    pub fn ready(&self, now: i64, wait: Wait) -> bool {
271        self.ready_in(now, &Holds::usual(wait))
272    }
273
274    /// Whether to tell it now: its time has come, it is not too late, the
275    /// matrix lets it come (an event's own, when its event begins within the
276    /// time now, where the cell says so), and work does not rest for it
277    /// (what a thing is for: work's reminders wait while work rests).
278    pub fn ready_in(&self, now: i64, holds: &Holds) -> bool {
279        let own = matches!(self.kind, Kind::Alarm | Kind::Before);
280        let event = attention::Event::own(row_of(self.kind)).starting(self.starts.filter(|_| own)).for_area(self.work.then_some(crate::areas::Area::WORK));
281        self.at <= now && now < self.until && holds.attention.decide(&event, &holds.now).told
282    }
283}
284
285/// The days and hours work happens: the working hours, else Monday to Friday 9:00–17:00.
286fn hours(config: &Config) -> Vec<AdminWindow> {
287    let hours = config.working_hours();
288    if hours.is_empty() { crate::window::default_office_hours() } else { hours }
289}
290
291fn working(hours: &[AdminWindow], time_off: &[TimeOff], date: Date) -> bool {
292    crate::window::open_day(hours, date) && crate::quiet::time_off_on(time_off, date).is_none()
293}
294
295/// When work starts, `days` working days before `date`.
296fn working_days_before(hours: &[AdminWindow], time_off: &[TimeOff], date: Date, days: u32, zone: &TimeZone) -> Option<Zoned> {
297    let mut day = date;
298    let mut left = days;
299    for _ in 0..90 {
300        day = day.yesterday().ok()?;
301        if working(hours, time_off, day) {
302            left -= 1;
303            if left == 0 {
304                return crate::window::hours_on(hours, day, zone).map(|(opening, _)| opening);
305            }
306        }
307    }
308    None
309}
310
311/// Half an hour before work ends, on the last working day before `date`.
312fn evening_before(hours: &[AdminWindow], time_off: &[TimeOff], date: Date, zone: &TimeZone) -> Option<Zoned> {
313    let mut day = date;
314    for _ in 0..90 {
315        day = day.yesterday().ok()?;
316        if working(hours, time_off, day) {
317            let (opening, closing) = crate::window::hours_on(hours, day, zone)?;
318            let before = closing.checked_sub(Span::new().minutes(30)).ok()?;
319            return Some(if before > opening { before } else { opening });
320        }
321    }
322    None
323}
324
325/// When work next starts, at or after `at`; `at` itself within working hours.
326fn work_from(hours: &[AdminWindow], time_off: &[TimeOff], at: &Zoned) -> Option<Zoned> {
327    if working(hours, time_off, at.date()) && crate::window::current(hours, at).is_some() {
328        return Some(at.clone());
329    }
330    crate::quiet::next_work(hours, time_off, at)
331}
332
333/// "2026-10-09" or "2026-10-09T14:00": its day, and the moment it ends (the end of the day without a time).
334fn asked_date(text: &str, zone: &TimeZone) -> Option<(Date, i64)> {
335    let day: Date = text.get(..10)?.parse().ok()?;
336    let time = text.get(11..16).and_then(|t| t.split_once(':')).and_then(|(h, m)| jiff::civil::Time::new(h.parse().ok()?, m.parse().ok()?, 0, 0).ok());
337    let end = match time {
338        Some(time) => day.to_datetime(time).to_zoned(zone.clone()).ok()?,
339        None => day.tomorrow().ok()?.to_zoned(zone.clone()).ok()?,
340    };
341    Some((day, end.timestamp().as_second()))
342}
343
344fn modified(path: &str) -> i64 {
345    std::fs::metadata(path).and_then(|m| m.modified()).ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_secs() as i64)
346}
347
348/// When Sioul reminds of an event before it (Unix seconds): its own time
349/// (`REMIND`) or the usual `minutes`, counted before its margin (getting
350/// ready, getting there): 14:00 with 30 minutes to get there and a quarter
351/// of an hour reminds at 13:15. A task's time block is an event like any
352/// other (docs/tasks.md, "Pinned to a time"). None for a whole day (the
353/// working day before tells it), for "not this one", and, unless they ask,
354/// for calendars you only read (holidays, subscriptions: their own alarms
355/// still come).
356pub fn before_at(event: &Occurrence, minutes: u32) -> Option<i64> {
357    if event.all_day {
358        return None;
359    }
360    let usual = if event.read_only { 0 } else { minutes };
361    let lead = Remind::read(&event.remind).minutes(usual);
362    (lead > 0).then(|| event.start - i64::from(event.margins.before + lead) * 60)
363}
364
365/// Everything to remind, told or not, from the events of the coming days, the
366/// tasks, and the payments planned; `personal` says which tasks are yours (they do not wait for work).
367pub fn all(config: &Config, tr: &Translator, now: &Zoned, events: &[Occurrence], tasks: &[Task], ledger: Option<&Ledger>, papers: &[crate::papers::Paper], contracts: &[crate::contracts::Contract], money: Option<&crate::bank::Watch>, personal: impl Fn(&Task) -> bool) -> Vec<Reminder> {
368    let zone = now.time_zone().clone();
369    let settings = &config.reminders;
370    let hours = hours(config);
371    let at = |seconds: i64| jiff::Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()));
372    let stamp = now.timestamp().as_second();
373    let mut out = Vec::new();
374    // Your addresses: an event you declined is not reminded.
375    let own: Vec<String> = config.every_account().filter_map(|a| a.address.clone()).collect();
376
377    for event in events.iter().filter(|e| !e.cancelled && !declined(e, &own)) {
378        let Some(start) = at(event.start) else { continue };
379        let when = if event.all_day { tr.day(start.date()) } else { tr.date(&start, true) };
380        let place = |text: String| if event.location.is_empty() { text } else if text.is_empty() { event.location.clone() } else { format!("{text} · {}", event.location) };
381        // Its own alarms: set by you or the one who invited you. The alarm's
382        // offset is the event file's: too far to be a time, none.
383        let mut alarms: Vec<(i64, i64)> = event.alarms.iter().filter_map(|offset| event.start.checked_add(*offset).filter(|due| jiff::Timestamp::from_second(*due).is_ok()).map(|due| (*offset, due))).collect();
384        // Sioul's before it, counted before its margin; one of its alarms
385        // within minutes of it makes one reminder, at the earlier.
386        if let Some(due) = before_at(event, settings.before_event)
387            && modified(&event.key) <= due
388            && !alarms.iter().any(|(_, alarm)| (alarm - due).abs() <= SAME_MOMENT && *alarm < due)
389        {
390            alarms.retain(|(_, alarm)| (alarm - due).abs() > SAME_MOMENT);
391            // Said as it is when told: at waking, the time left is less.
392            let told = due.max(stamp);
393            let hm = |seconds: i64| at(seconds).map(|z| z.strftime("%H:%M").to_string()).unwrap_or_default();
394            let mut args = crate::i18n::args();
395            args.set("time", if at(told).is_some_and(|t| t.date() == start.date()) { hm(event.start) } else { tr.date(&start, true) });
396            args.set("what", if event.summary.is_empty() { tr.text("agenda-untitled", None) } else { event.summary.clone() });
397            args.set("in", in_text(tr, event.start - told));
398            let ready = event.start - i64::from(event.margins.before) * 60;
399            let margin = match event.margins.before {
400                0 => String::new(),
401                _ if ready <= told => tr.text("reminder-margin-now", None),
402                _ => {
403                    let mut from = crate::i18n::args();
404                    from.set("time", hm(ready));
405                    tr.text("reminder-margin", Some(&from))
406                }
407            };
408            out.push(Reminder {
409                key: format!("before:{}:{}", event.uid, event.start),
410                kind: Kind::Before,
411                at: due,
412                until: event.start,
413                title: tr.text("reminder-before", Some(&args)),
414                body: [margin, event.location.clone()].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join("\n"),
415                target: event.key.clone(),
416                work: false,
417                starts: Some(event.start),
418            });
419        }
420        for (offset, due) in alarms {
421            out.push(Reminder {
422                key: format!("alarm:{}:{}:{offset}", event.uid, event.start),
423                kind: Kind::Alarm,
424                at: due,
425                until: event.start.max(due) + 15 * 60,
426                title: event.summary.clone(),
427                body: place(when.clone()),
428                target: event.key.clone(),
429                work: false,
430                starts: Some(event.start),
431            });
432        }
433        // The working day before; not for calendars you only read (holidays, subscriptions).
434        if settings.events && !event.read_only
435            && let Some(due) = evening_before(&hours, &config.time_off, start.date(), &zone)
436            && modified(&event.key) <= due.timestamp().as_second()
437        {
438            let mut args = crate::i18n::args();
439            args.set("when", when.clone());
440            args.set("what", event.summary.clone());
441            out.push(Reminder {
442                key: format!("event:{}:{}", event.uid, event.start),
443                kind: Kind::Event,
444                at: due.timestamp().as_second(),
445                until: event.start,
446                title: tr.text("reminder-event", Some(&args)),
447                body: event.location.clone(),
448                target: event.key.clone(),
449                work: false,
450                starts: None,
451            });
452        }
453    }
454
455    let open = |t: &&Task| matches!(t.status, Status::NeedsAction | Status::InProcess);
456    for task in tasks.iter().filter(open) {
457        // A date asked, a few working days before.
458        if settings.asked_days > 0
459            && let Some((day, end)) = asked_date(&task.due, &zone)
460            && let Some(due) = working_days_before(&hours, &config.time_off, day, settings.asked_days, &zone)
461            && task.created <= due.timestamp().as_second()
462        {
463            let mut args = crate::i18n::args();
464            args.set("date", tr.day(day));
465            out.push(Reminder {
466                key: format!("asked:{}:{}", task.uid, task.due),
467                kind: Kind::Asked,
468                at: due.timestamp().as_second(),
469                until: end,
470                title: tr.text("reminder-asked", Some(&args)),
471                body: task.title.clone(),
472                target: format!("sioul:task/{}", task.uid),
473                work: !personal(task),
474                starts: None,
475            });
476        }
477        // A wait after a step done: once it is over, when work is there.
478        if settings.waits {
479            for relation in task.relations.iter().filter(|r| r.kind.eq_ignore_ascii_case("FINISHTOSTART") && r.gap > 0) {
480                let Some(before) = tasks.iter().find(|t| t.uid == relation.uid) else { continue };
481                // The gap is the task file's: one too long to be a time waits for nothing.
482                let Some(over) = before.completed.and_then(|done| done.checked_add(relation.gap.checked_mul(60)?)).and_then(at) else { continue };
483                let Some(due) = work_from(&hours, &config.time_off, &over) else { continue };
484                let mut args = crate::i18n::args();
485                args.set("before", before.title.clone());
486                out.push(Reminder {
487                    key: format!("wait:{}:{}", task.uid, over.timestamp().as_second()),
488                    kind: Kind::Wait,
489                    at: due.timestamp().as_second(),
490                    until: over.timestamp().as_second() + 7 * 86_400,
491                    title: task.title.clone(),
492                    body: tr.text("reminder-wait", Some(&args)),
493                    target: format!("sioul:task/{}", task.uid),
494                    work: !personal(task),
495                    starts: None,
496                });
497            }
498        }
499    }
500
501    // Payments planned (a bill to pay, a tax), a few working days before.
502    if settings.payment_days > 0
503        && let Some(ledger) = ledger
504    {
505        let today = now.date();
506        let horizon = today.checked_add(Span::new().days(30)).unwrap_or(today);
507        for line in ledger.lines.iter().filter(|l| l.planned && l.amount.cents() < 0 && l.date >= today && l.date <= horizon) {
508            let Some(due) = working_days_before(&hours, &config.time_off, line.date, settings.payment_days, &zone) else { continue };
509            let Some(end) = line.date.tomorrow().ok().and_then(|d| d.to_zoned(zone.clone()).ok()) else { continue };
510            let mut args = crate::i18n::args();
511            args.set("date", tr.day(line.date));
512            // The balance read first: a reminder that ignores it can push an account into overdraft (Medina 2021).
513            let body = match money.and_then(|w| w.balance_on(line.date)) {
514                Some(after) if after.is_negative() => {
515                    args.set("short", tr.money(crate::money::Money(-after.cents())));
516                    tr.text("reminder-payment-short", Some(&args))
517                }
518                Some(_) => tr.text("reminder-payment-held", Some(&args)),
519                None => tr.text("reminder-payment", Some(&args)),
520            };
521            out.push(Reminder {
522                key: format!("payment:{}:{}:{}", line.budget, line.label, line.date),
523                kind: Kind::Payment,
524                at: due.timestamp().as_second(),
525                until: end.timestamp().as_second(),
526                title: format!("{} · {}", line.label, tr.money(crate::money::Money(-line.amount.cents()))),
527                body,
528                target: format!("sioul:budget/{}", line.budget),
529                work: false,
530                starts: None,
531            });
532        }
533    }
534    // Papers to renew, when renewing starts; not once a renewal task is made (it has its own date).
535    for paper in papers.iter().filter(|p| p.renewal.is_empty()) {
536        let (Some(from), Some(until)) = (paper.renew_from(), paper.until) else { continue };
537        if paper.added.is_some_and(|added| added > from) {
538            continue;
539        }
540        let Some(due) = from.to_zoned(zone.clone()).ok().and_then(|z| work_from(&hours, &config.time_off, &z)) else { continue };
541        let Some(end) = until.tomorrow().ok().and_then(|d| d.to_zoned(zone.clone()).ok()) else { continue };
542        let mut args = crate::i18n::args();
543        args.set("title", paper.title.clone());
544        args.set("date", tr.day_in(until, now.date()));
545        out.push(Reminder {
546            key: format!("paper:{}:{until}", paper.id),
547            kind: Kind::Paper,
548            at: due.timestamp().as_second(),
549            until: end.timestamp().as_second(),
550            title: tr.text("paper-reminder", Some(&args)),
551            body: tr.text(&format!("paper-renew-{}", paper.kind.family()), None),
552            target: format!("sioul:paper/{}", paper.id),
553            work: false,
554            starts: None,
555        });
556    }
557    // Contracts renewing each year: two weeks before the last day a notice can leave.
558    for contract in contracts.iter().filter(|c| c.is_open() && c.every == "year") {
559        let (Some(renewal), Some(by)) = (contract.next_renewal(now.date()), contract.cancel_by(now.date())) else { continue };
560        let Some(from) = by.checked_sub(Span::new().days(14)).ok().and_then(|d| d.to_zoned(zone.clone()).ok()) else { continue };
561        let Some(due) = work_from(&hours, &config.time_off, &from) else { continue };
562        let Some(end) = by.tomorrow().ok().and_then(|d| d.to_zoned(zone.clone()).ok()) else { continue };
563        let mut args = crate::i18n::args();
564        args.set("title", contract.title.clone());
565        args.set("date", tr.day_in(renewal, now.date()));
566        args.set("by", tr.day_in(by, now.date()));
567        out.push(Reminder {
568            key: format!("contract:{}:{renewal}", contract.id),
569            kind: Kind::Contract,
570            at: due.timestamp().as_second(),
571            until: end.timestamp().as_second(),
572            title: tr.text("contract-reminder", Some(&args)),
573            body: tr.text(if contract.notice_days > 0 { "contract-reminder-notice" } else { "contract-reminder-free" }, Some(&args)),
574            target: format!("sioul:contract/{}", contract.id),
575            work: false,
576            starts: None,
577        });
578    }
579    // The money watch: once each, calmly.
580    for finding in money.map(|w| w.findings.as_slice()).unwrap_or_default() {
581        let mut args = crate::i18n::args();
582        let (key, at, until, title, body) = match finding {
583            crate::bank::Finding::Missed { label, amount, date } => {
584                args.set("label", label.clone());
585                args.set("amount", tr.money(amount.abs()));
586                args.set("date", tr.day_in(*date, now.date()));
587                let Some(from) = date.checked_add(Span::new().days(7)).ok().and_then(|d| d.to_zoned(zone.clone()).ok()) else { continue };
588                let Some(due) = work_from(&hours, &config.time_off, &from) else { continue };
589                (format!("missed:{label}:{date}"), due, from.timestamp().as_second() + 30 * 86_400, tr.text(if amount.is_negative() { "money-missed-out" } else { "money-missed-in" }, Some(&args)), tr.text("money-missed-why", Some(&args)))
590            }
591            crate::bank::Finding::Short { label, amount, date, short, reserve } => {
592                args.set("label", label.clone());
593                args.set("amount", tr.money(amount.abs()));
594                args.set("date", tr.day_in(*date, now.date()));
595                args.set("short", tr.money(*short));
596                let body = match reserve {
597                    Some(reserve) => {
598                        args.set("reserve", reserve.clone());
599                        tr.text("money-short-reserve", Some(&args))
600                    }
601                    None => tr.text("money-short-ask", Some(&args)),
602                };
603                let Some(due) = working_days_before(&hours, &config.time_off, *date, 5, &zone) else { continue };
604                let Some(end) = date.tomorrow().ok().and_then(|d| d.to_zoned(zone.clone()).ok()) else { continue };
605                (format!("short:{label}:{date}"), due, end.timestamp().as_second(), tr.text("money-short", Some(&args)), body)
606            }
607            crate::bank::Finding::Changed { .. } => continue,
608        };
609        out.push(Reminder { key, kind: Kind::Money, at: at.timestamp().as_second(), until, title, body, target: "sioul:budget/".into(), work: false, starts: None });
610    }
611    out.sort_by(|a, b| (a.at, &a.key).cmp(&(b.at, &b.key)));
612    out
613}
614
615/// Everything to remind, read from the files now: the events of the coming
616/// days, the tasks, the payments planned in the notes folder; and what holds now.
617pub fn gather(config: &Config, tr: &Translator, now: &Zoned) -> (Vec<Reminder>, Holds) {
618    let stamp = now.timestamp().as_second();
619    let events = crate::agenda::occurrences(stamp - 86_400, stamp + 9 * 86_400);
620    let tasks = crate::tasks::all(now.time_zone());
621    let store = config.notes_root_path();
622    let looked = crate::words::Words::of(config);
623    let ledger = store.as_deref().and_then(|root| Ledger::load_with_bank(root, &looked).ok());
624    let projects = store.as_deref().and_then(|root| crate::projects::ProjectStore::load(root).ok()).map(|s| s.projects).unwrap_or_default();
625    let overrides = crate::quiet::Overrides::load(&crate::quiet::Overrides::default_path());
626    // Meals and sleep as Health keeps them: while you sleep, nothing is told.
627    let blocks = crate::quiet::Blocks::read(now, &events);
628    let situation = crate::quiet::Situation::now(config, &overrides, &blocks, now, tr, &projects);
629    let papers = store.as_deref().and_then(|root| crate::papers::Wallet::load(root).ok()).map(|w| w.papers).unwrap_or_default();
630    let contracts = store.as_deref().and_then(|root| crate::contracts::Contracts::load(root).ok()).map(|c| c.list).unwrap_or_default();
631    let money = store.as_deref().and_then(|root| crate::bank::Bank::load(root).ok()).filter(|b| !b.movements.is_empty() || !b.accounts.is_empty()).zip(ledger.as_ref()).map(|(bank, ledger)| crate::bank::watch(&bank, ledger, now.date(), &looked.bank.filler));
632    let reminders = all(config, tr, now, &events, &tasks, ledger.as_ref(), &papers, &contracts, money.as_ref(), |t| situation.quiet_tasks.keeps(t));
633    (reminders, Holds::of(config, &situation.mode, &blocks, stamp))
634}
635
636/// The events' reminders alone (their alarms, Sioul's before them, the
637/// working day before), read from the files now, and what holds now: what a
638/// phone's alarm asks at its time (docs/android.md, "Events"), lighter than
639/// `gather`.
640pub fn gather_events(config: &Config, tr: &Translator, now: &Zoned) -> (Vec<Reminder>, Holds) {
641    let stamp = now.timestamp().as_second();
642    let events = crate::agenda::occurrences(stamp - 86_400, stamp + 9 * 86_400);
643    let overrides = crate::quiet::Overrides::load(&crate::quiet::Overrides::default_path());
644    let blocks = crate::quiet::Blocks::read(now, &events);
645    let mode = crate::quiet::mode(&config.week_hours(), &config.time_off, &overrides, &blocks, now);
646    let reminders = all(config, tr, now, &events, &[], None, &[], &[], None, |_| true);
647    (reminders, Holds::of(config, &mode, &blocks, stamp))
648}
649
650/// What waits now (`Wait`), from what now is for: the pause first (its time
651/// is sleep's too), then sleep, from its block in Health (the night from
652/// winding down to waking, a nap), then Free time until it ends, then work
653/// resting.
654pub fn wait_of(mode: &crate::quiet::Mode, blocks: &crate::quiet::Blocks, stamp: i64) -> Wait {
655    let until = mode.until.as_ref().map_or(stamp, |u| u.timestamp().as_second());
656    if mode.paused() {
657        Wait::Paused
658    } else if mode.sleeps() {
659        blocks.at(stamp, &["sleep", "nap"]).map_or(Wait::Sleep { from: stamp, until }, |block| Wait::Sleep { from: block.start, until: block.end })
660    } else if mode.free() {
661        Wait::Free { until }
662    } else if mode.quiet {
663        Wait::Work
664    } else {
665        Wait::Nothing
666    }
667}
668
669/// Where reminders told are marked, one small file each.
670pub fn told_dir() -> PathBuf {
671    crate::config::state_dir().join("reminded")
672}
673
674fn fnv(text: &str) -> u64 {
675    text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
676}
677
678/// Marks a reminder told: true for whoever marks it first, the window or
679/// `sioul remind`, so it is told once whichever runs.
680pub fn claim(dir: &Path, key: &str) -> bool {
681    let _ = std::fs::create_dir_all(dir);
682    match std::fs::OpenOptions::new().write(true).create_new(true).open(dir.join(format!("{:016x}", fnv(key)))) {
683        Ok(mut file) => {
684            let _ = file.write_all(key.as_bytes());
685            true
686        }
687        Err(_) => false,
688    }
689}
690
691/// Whether a reminder was told already.
692pub fn told(dir: &Path, key: &str) -> bool {
693    dir.join(format!("{:016x}", fnv(key))).exists()
694}
695
696/// Marks older than two months forgotten: their dates are past.
697pub fn forget_old(dir: &Path) {
698    let limit = std::time::SystemTime::now() - std::time::Duration::from_secs(60 * 86_400);
699    for entry in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok) {
700        if entry.metadata().and_then(|m| m.modified()).is_ok_and(|t| t < limit) {
701            let _ = std::fs::remove_file(entry.path());
702        }
703    }
704}
705
706/// What to tell now, each marked told: those ready, not told yet.
707pub fn to_tell(reminders: Vec<Reminder>, dir: &Path, now: i64, holds: &Holds) -> Vec<Reminder> {
708    reminders.into_iter().filter(|r| r.ready_in(now, holds) && claim(dir, &r.key)).collect()
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    fn zone() -> TimeZone {
716        TimeZone::get("Europe/Paris").unwrap()
717    }
718
719    fn at(text: &str) -> Zoned {
720        text.parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone()).unwrap()
721    }
722
723    fn config() -> Config {
724        toml::from_str(
725            "[[window]]\nday = \"monday\"\nstart = \"09:00\"\nend = \"17:00\"\n[[window]]\nday = \"tuesday\"\nstart = \"09:00\"\nend = \"17:00\"\n\
726             [[window]]\nday = \"wednesday\"\nstart = \"09:00\"\nend = \"17:00\"\n[[window]]\nday = \"thursday\"\nstart = \"09:00\"\nend = \"17:00\"\n\
727             [[window]]\nday = \"friday\"\nstart = \"10:00\"\nend = \"16:00\"\n",
728        )
729        .unwrap()
730    }
731
732    fn event(uid: &str, start: &str, alarms: Vec<i64>) -> Occurrence {
733        let start = at(start).timestamp().as_second();
734        Occurrence {
735            key: "/nonexistent/event.ics".into(),
736            uid: uid.into(),
737            summary: "Dentist".into(),
738            location: "12 rue Example".into(),
739            notes: String::new(),
740            start,
741            end: start + 3600,
742            all_day: false,
743            recurring: false,
744            calendar: "Personal".into(),
745            color: None,
746            cancelled: false,
747            tentative: false,
748            organizer: String::new(),
749            attendees: vec![],
750            read_only: false,
751            alarms,
752            ..Occurrence::default()
753        }
754    }
755
756    fn task(uid: &str, due: &str) -> Task {
757        Task { uid: uid.into(), title: format!("Task {uid}"), due: due.into(), created: at("2026-09-01T10:00").timestamp().as_second(), ..Task::default() }
758    }
759
760    #[test]
761    fn once_each_at_its_time() {
762        let tr = Translator::new("en");
763        let config = config();
764        let now = at("2026-10-05T08:00"); // a Monday
765        // Monday 12 October at 9:00: told on Friday 9 October at 15:30 (Friday ends at 16:00), and at its alarm.
766        let events = vec![event("dentist", "2026-10-12T09:00", vec![-3600])];
767        // Asked for Thursday 8 October: two working days before, Tuesday at 9:00.
768        let mut tasks = vec![task("form", "2026-10-08")];
769        // A letter sent on Friday 2 October at 11:00, its answer waited 2 days: Sunday 11:00 → Monday 9:00.
770        let mut letter = task("letter", "");
771        letter.status = Status::Completed;
772        letter.completed = Some(at("2026-10-02T11:00").timestamp().as_second());
773        let mut follow = task("follow-up", "");
774        follow.relations = vec![crate::tasks::Relation { kind: "FINISHTOSTART".into(), uid: "letter".into(), gap: 2 * 1440 }];
775        tasks.extend([letter, follow]);
776        let ledger: Ledger = toml::from_str("[[line]]\nbudget = \"home\"\ndate = 2026-10-15\namount = -120.50\nlabel = \"Water bill\"\nplanned = true\n").unwrap();
777        // A passport ending on 30 December: renewing starts on 1 October, a Thursday, told when work starts.
778        let passport = crate::papers::Paper { id: "passport".into(), kind: crate::papers::Kind::Passport, title: "Passport".into(), until: Some("2026-12-30".parse().unwrap()), ..Default::default() };
779        // A phone plan renewing each year on 20 November, 10 days' notice: by 10 November, told from 27 October.
780        let phone = crate::contracts::Contract { id: "phone".into(), title: "Phone".into(), renews: Some("2025-11-20".parse().unwrap()), every: "year".into(), notice_days: 10, ..Default::default() };
781        // The bank says the account will be short on 15 October, when the water bill leaves; the savings cover it.
782        let money = crate::bank::Watch {
783            findings: vec![crate::bank::Finding::Short { label: "Water bill".into(), amount: crate::money::Money(-12050), date: "2026-10-15".parse().unwrap(), short: crate::money::Money(4000), reserve: Some("Savings".into()) }],
784            forecast: vec![("2026-10-15".parse().unwrap(), crate::money::Money(-4000))],
785            ..Default::default()
786        };
787        let all = all(&config, &tr, &now, &events, &tasks, Some(&ledger), &[passport], &[phone], Some(&money), |_| false);
788        let find = |prefix: &str| all.iter().find(|r| r.key.starts_with(prefix)).unwrap_or_else(|| panic!("{prefix}: {all:#?}"));
789        assert_eq!(find("event:").at, at("2026-10-09T15:30").timestamp().as_second());
790        assert_eq!(find("event:").title, "Mon 12 Oct 09:00 · Dentist");
791        assert_eq!(find("alarm:").at, at("2026-10-12T08:00").timestamp().as_second());
792        assert_eq!(find("asked:").at, at("2026-10-06T09:00").timestamp().as_second());
793        assert_eq!(find("asked:").title, "Asked for Thursday 8 October");
794        assert_eq!(find("wait:").at, at("2026-10-05T09:00").timestamp().as_second());
795        assert_eq!(find("payment:").at, at("2026-10-13T09:00").timestamp().as_second());
796        assert!(find("payment:").title.contains("Water bill") && find("payment:").title.contains("120.50"), "{}", find("payment:").title);
797        assert_eq!(find("paper:").at, at("2026-10-01T09:00").timestamp().as_second());
798        assert_eq!(find("paper:").title, "Passport: valid until Wednesday 30 December");
799        assert_eq!(find("contract:").at, at("2026-10-27T09:00").timestamp().as_second());
800        assert_eq!(find("contract:").body, "To stop it, the notice must leave by Tuesday 10 November.");
801        assert_eq!(find("short:").at, at("2026-10-08T09:00").timestamp().as_second(), "five working days before");
802        assert_eq!(find("short:").title, "The account may not hold Water bill on Thursday 15 October");
803        assert!(find("short:").body.contains("Savings covers it"), "{}", find("short:").body);
804        assert!(find("payment:").body.contains("short then"), "the payment reminder reads the balance: {}", find("payment:").body);
805
806        // Told once, by whoever comes first; work waits while work rests.
807        let dir = std::env::temp_dir().join(format!("sioul-reminded-{}", std::process::id()));
808        let _ = std::fs::remove_dir_all(&dir);
809        let tuesday = at("2026-10-06T09:05").timestamp().as_second();
810        // Asleep: nothing told, nothing marked; told at waking.
811        let asleep = Wait::Sleep { from: tuesday - 8 * 3600, until: tuesday + 3600 };
812        assert!(to_tell(all.clone(), &dir, tuesday, &Holds::usual(asleep)).is_empty());
813        let mut told = to_tell(all.clone(), &dir, tuesday, &Holds::usual(Wait::Nothing));
814        told.retain(|r| r.kind == Kind::Asked);
815        assert_eq!(told.len(), 1);
816        assert!(to_tell(all.clone(), &dir, tuesday + 60, &Holds::usual(Wait::Nothing)).iter().all(|r| r.kind != Kind::Asked), "never twice");
817        let wait = find("wait:").clone();
818        assert!(!wait.ready(tuesday, Wait::Work) && wait.ready(tuesday, Wait::Nothing));
819        let night = Wait::Sleep { from: find("payment:").at - 3600, until: find("payment:").at + 3600 };
820        assert!(!find("payment:").clone().ready(find("payment:").at, night) && find("payment:").ready(find("payment:").at, Wait::Work), "yours wait only for waking");
821        // Too late: the day asked is over.
822        assert!(!find("asked:").ready(at("2026-10-09T08:00").timestamp().as_second(), Wait::Nothing));
823        let _ = std::fs::remove_dir_all(&dir);
824    }
825
826    #[test]
827    fn not_for_what_was_just_made() {
828        let tr = Translator::new("fr");
829        let config = config();
830        let now = at("2026-10-06T10:00");
831        // Asked for Wednesday, made on Tuesday at 10:00, after its reminder (Monday 9:00): not told.
832        let mut late = task("late", "2026-10-07T12:00");
833        late.created = now.timestamp().as_second();
834        // Done already: nothing.
835        let mut done = task("done", "2026-10-09");
836        done.status = Status::Completed;
837        let all = all(&config, &tr, &now, &[], &[late, done], None, &[], &[], None, |_| true);
838        assert!(all.is_empty(), "{all:#?}");
839        // Time off moves the working days.
840        let mut off = self::config();
841        off.time_off = vec![TimeOff { id: String::new(), from: "2026-10-12".parse().unwrap(), until: "2026-10-13".parse().unwrap(), label: String::new() }];
842        let all = super::all(&off, &tr, &now, &[], &[task("after", "2026-10-14")], None, &[], &[], None, |_| true);
843        assert_eq!(all[0].at, at("2026-10-08T09:00").timestamp().as_second(), "Thursday and Friday before the days off");
844        assert_eq!(all[0].title, "Demandé pour mercredi 14 octobre");
845        // A wait or an alarm too long to be a time, from someone else's file: nothing, and no crash.
846        let mut sent = task("sent", "");
847        sent.status = Status::Completed;
848        sent.completed = Some(now.timestamp().as_second());
849        let mut answer = task("answer", "");
850        answer.relations = vec![crate::tasks::Relation { kind: "FINISHTOSTART".into(), uid: "sent".into(), gap: i64::MAX / 7 }];
851        let odd = event("odd", "2026-10-12T09:00", vec![i64::MAX - 10]);
852        assert!(super::all(&config, &tr, &now, &[odd], &[sent, answer], None, &[], &[], None, |_| true).iter().all(|r| r.kind != Kind::Wait && r.kind != Kind::Alarm));
853    }
854
855    /// Sioul's own reminders before events, as told at `now`.
856    fn befores(config: &Config, tr: &Translator, now: &Zoned, events: &[Occurrence]) -> Vec<Reminder> {
857        all(config, tr, now, events, &[], None, &[], &[], None, |_| true).into_iter().filter(|r| matches!(r.kind, Kind::Before | Kind::Alarm)).collect()
858    }
859
860    #[test]
861    fn before_an_event() {
862        let en = Translator::new("en");
863        let config = config();
864        let now = at("2026-10-07T08:00"); // a Wednesday
865        let stamp = |text: &str| at(text).timestamp().as_second();
866        // The usual quarter of an hour; with 30 minutes to get there, before them.
867        let plain = event("dentist", "2026-10-07T14:00", vec![]);
868        let told = befores(&config, &en, &now, std::slice::from_ref(&plain));
869        assert_eq!((told.len(), told[0].kind, told[0].at), (1, Kind::Before, stamp("2026-10-07T13:45")));
870        assert_eq!((told[0].title.as_str(), told[0].body.as_str()), ("14:00 · Dentist, in 15 minutes", "12 rue Example"));
871        assert_eq!((told[0].until, told[0].starts), (stamp("2026-10-07T14:00"), Some(stamp("2026-10-07T14:00"))), "it makes sense until the event begins");
872        let far = Occurrence { margins: crate::demands::Margins { before: 30, after: 0 }, ..plain.clone() };
873        let told = befores(&config, &en, &now, std::slice::from_ref(&far));
874        assert_eq!(told[0].at, stamp("2026-10-07T13:15"));
875        assert_eq!((told[0].title.as_str(), told[0].body.as_str()), ("14:00 · Dentist, in 45 minutes", "Getting ready, getting there: from 13:30.\n12 rue Example"));
876        // Told late (at waking, say): the time left as it is then.
877        let late = befores(&config, &en, &at("2026-10-07T13:40"), std::slice::from_ref(&far));
878        assert_eq!((late[0].title.as_str(), late[0].body.as_str()), ("14:00 · Dentist, in 20 minutes", "Getting ready, getting there: now.\n12 rue Example"));
879        // In French, and an hour and a half ahead.
880        let fr = Translator::new("fr");
881        let long = Occurrence { remind: "60".into(), ..far.clone() };
882        let dit = befores(&config, &fr, &now, std::slice::from_ref(&long));
883        assert_eq!(dit[0].at, stamp("2026-10-07T12:30"));
884        assert_eq!((dit[0].title.as_str(), dit[0].body.as_str()), ("14:00 · Dentist, dans 1 h 30", "Se préparer, y aller\u{202f}: dès 13:30.\n12 rue Example"));
885        assert_eq!([60, 90 * 60, 2 * 3600, 20].map(|s| in_text(&en, s)), ["in one minute", "in 1 h 30 min", "in 2 hours", "now"]);
886
887        // The event's own choice: not this one; ten minutes; the usual time none, its own still.
888        assert!(befores(&config, &en, &now, &[Occurrence { remind: "NONE".into(), ..plain.clone() }]).is_empty());
889        assert_eq!(befores(&config, &en, &now, &[Occurrence { remind: "10".into(), ..plain.clone() }])[0].at, stamp("2026-10-07T13:50"));
890        let mut none = config.clone();
891        none.reminders.before_event = 0;
892        assert!(befores(&none, &en, &now, std::slice::from_ref(&plain)).is_empty());
893        assert_eq!(befores(&none, &en, &now, &[Occurrence { remind: "5".into(), ..plain.clone() }])[0].at, stamp("2026-10-07T13:55"));
894        assert_eq!((Remind::read(""), Remind::read("none"), Remind::read("0"), Remind::read("15"), Remind::read("soon")), (Remind::Usual, Remind::Never, Remind::Never, Remind::Minutes(15), Remind::Usual));
895        assert_eq!((Remind::Never.value(), Remind::Minutes(30).value(), Remind::Usual.value()), (Some("NONE".to_string()), Some("30".to_string()), None));
896        // Not for a whole day, a calendar you only read, an event cancelled or declined; a task's time block as any event.
897        assert!(befores(&config, &en, &now, &[Occurrence { all_day: true, ..plain.clone() }]).is_empty());
898        assert!(befores(&config, &en, &now, &[Occurrence { read_only: true, ..plain.clone() }]).is_empty());
899        assert_eq!(befores(&config, &en, &now, &[Occurrence { read_only: true, remind: "30".into(), ..plain.clone() }]).len(), 1, "unless it asks");
900        assert!(befores(&config, &en, &now, &[Occurrence { cancelled: true, ..plain.clone() }]).is_empty());
901        let mut mine = config.clone();
902        mine.accounts = toml::from_str::<Config>("[[account]]\nid = \"home\"\nkind = \"imap\"\naddress = \"me@example.net\"\n").unwrap().accounts;
903        let invited = |answer: &str| Occurrence { attendees: vec![crate::agenda::Attendee { name: "Me".into(), address: "ME@example.net".into(), answer: answer.into() }], ..plain.clone() };
904        assert!(befores(&mine, &en, &now, &[invited("declined")]).is_empty());
905        assert_eq!(befores(&mine, &en, &now, &[invited("accepted")]).len(), 1);
906        assert_eq!(befores(&config, &en, &now, &[Occurrence { task: "bank".into(), ..plain.clone() }]).len(), 1);
907
908        // One reminder per moment: an alarm within five minutes and Sioul's make one, the earlier.
909        let same = befores(&config, &en, &now, &[event("dentist", "2026-10-07T14:00", vec![-15 * 60])]);
910        assert_eq!(same.iter().map(|r| r.kind).collect::<Vec<_>>(), [Kind::Before]);
911        let earlier = befores(&config, &en, &now, &[event("dentist", "2026-10-07T14:00", vec![-20 * 60])]);
912        assert_eq!(earlier.iter().map(|r| (r.kind, r.at)).collect::<Vec<_>>(), [(Kind::Alarm, stamp("2026-10-07T13:40"))]);
913        let apart = befores(&config, &en, &now, &[event("dentist", "2026-10-07T14:00", vec![-60 * 60])]);
914        assert_eq!(apart.iter().map(|r| r.kind).collect::<Vec<_>>(), [Kind::Alarm, Kind::Before]);
915
916        // Sleep: an event in the night is reminded in it; one after waking waits for waking.
917        let night = Wait::Sleep { from: stamp("2026-10-07T23:00"), until: stamp("2026-10-08T07:00") };
918        let flight = &befores(&config, &en, &now, &[event("flight", "2026-10-08T03:00", vec![])])[0];
919        assert!(flight.ready(stamp("2026-10-08T02:45"), night), "a choice you made");
920        let train = Occurrence { margins: crate::demands::Margins { before: 60, after: 0 }, ..event("train", "2026-10-08T08:00", vec![]) };
921        let early = &befores(&config, &en, &now, std::slice::from_ref(&train))[0];
922        assert_eq!(early.at, stamp("2026-10-08T06:45"));
923        assert!(!early.ready(stamp("2026-10-08T06:45"), night) && early.ready(stamp("2026-10-08T07:00"), Wait::Nothing));
924        let woken = befores(&config, &en, &at("2026-10-08T07:00"), std::slice::from_ref(&train));
925        assert_eq!(woken[0].title, "08:00 · Dentist, in one hour", "said at waking");
926        // The pause: an event's own reminders come (the event falls in it); the working day before waits.
927        assert!(flight.ready(stamp("2026-10-08T02:45"), Wait::Paused));
928        let eve = all(&config, &en, &now, &[event("dentist", "2026-10-12T09:00", vec![])], &[], None, &[], &[], None, |_| true);
929        let evening = eve.iter().find(|r| r.kind == Kind::Event).unwrap();
930        assert!(!evening.ready(evening.at, Wait::Paused) && !evening.ready(evening.at, night_of(evening.at)));
931        // Free time: an event within it comes, one after it waits for its end.
932        let dinner = &befores(&config, &en, &now, &[event("dinner", "2026-10-07T20:00", vec![])])[0];
933        assert!(dinner.ready(dinner.at, Wait::Free { until: stamp("2026-10-07T22:30") }));
934        assert!(!dinner.ready(dinner.at, Wait::Free { until: stamp("2026-10-07T19:00") }));
935
936        // Kept in the event's own file, as Sioul's property: written, read back, changed, taken away.
937        let edit = crate::agenda::EventEdit { title: "Choir".into(), start: "2026-10-07T20:00".into(), end: "2026-10-07T22:00".into(), repeat: "weekly".into(), remind: "30".into(), ..Default::default() };
938        let master = crate::agenda::new_event(&edit, &zone()).unwrap();
939        assert!(master.contains("X-SIOUL-REMIND:30") && !master.contains("VALARM"), "{master}");
940        assert_eq!(crate::agenda::edit_of_text(&master, &zone()).unwrap().remind, "30");
941        let never = crate::agenda::apply(&master, &crate::agenda::EventEdit { remind: "none".into(), ..edit.clone() }, &zone()).unwrap();
942        assert!(never.contains("X-SIOUL-REMIND:NONE") && !never.contains("X-SIOUL-REMIND:30"), "{never}");
943        assert_eq!(crate::agenda::edit_of_text(&never, &zone()).unwrap().remind, "none");
944        let usual = crate::agenda::apply(&never, &crate::agenda::EventEdit { remind: String::new(), ..edit.clone() }, &zone()).unwrap();
945        assert!(!usual.contains("X-SIOUL-REMIND"), "{usual}");
946        let retitled = crate::agenda::apply(&master, &crate::agenda::EventEdit { title: "Choir practice".into(), ..edit.clone() }, &zone()).unwrap();
947        assert!(retitled.contains("X-SIOUL-REMIND:30"), "kept when something else changes");
948
949        // Recurring: each occurrence its own, from the file; a changed one keeps the series' choice.
950        let uid = crate::lines::unfold(&master).iter().find(|l| crate::lines::name(l) == "UID").map(|l| crate::lines::value(l).trim().to_string()).unwrap();
951        let changed = format!(
952            "BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261001T000000Z\r\nRECURRENCE-ID;TZID=Europe/Paris:20261014T200000\r\nDTSTART;TZID=Europe/Paris:20261014T193000\r\n\
953             DTEND;TZID=Europe/Paris:20261014T213000\r\nSUMMARY:Choir, earlier\r\nEND:VEVENT\r\n"
954        );
955        let dir = std::env::temp_dir().join(format!("sioul-remind-series-{}", std::process::id()));
956        let _ = std::fs::remove_dir_all(&dir);
957        std::fs::create_dir_all(&dir).unwrap();
958        let path = dir.join("choir.ics");
959        std::fs::write(&path, master.replace("END:VCALENDAR", &format!("{changed}END:VCALENDAR"))).unwrap();
960        // Written long ago: "changed after its reminder" never holds it back here.
961        std::fs::File::options().write(true).open(&path).unwrap().set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_750_000_000)).unwrap();
962        let calendar = crate::vdir::Collection { kind: crate::vdir::Kind::Calendars, account: "a".into(), id: "c".into(), dir: dir.clone(), name: "Personal".into(), color: None, read_only: false, components: Vec::new() };
963        let series = crate::agenda::file_occurrences(&path, &calendar, stamp("2026-10-07T00:00"), stamp("2026-10-22T00:00"), &zone());
964        let told = befores(&config, &en, &now, &series);
965        assert_eq!(told.iter().map(|r| r.at).collect::<Vec<_>>(), [stamp("2026-10-07T19:30"), stamp("2026-10-14T19:00"), stamp("2026-10-21T19:30")], "{told:#?}");
966        assert_eq!(told.iter().map(|r| r.key.as_str()).collect::<std::collections::BTreeSet<_>>().len(), 3, "each its own, told once each");
967        assert_eq!(told[1].title, "19:30 · Choir, earlier, in 30 minutes");
968        let _ = std::fs::remove_dir_all(&dir);
969    }
970
971    /// A night around `stamp` that holds nothing of the events tested.
972    fn night_of(stamp: i64) -> Wait {
973        Wait::Sleep { from: stamp - 3600, until: stamp + 3600 }
974    }
975
976    #[test]
977    fn what_waits_now() {
978        use crate::areas::{Time, Week};
979        use crate::quiet::{Blocks, Mode, Reason};
980        let now = at("2026-10-07T23:30").timestamp().as_second();
981        let mode = |time: Time, reason: Reason, until: Option<&str>| Mode { quiet: true, time, week: Week::default(), reason, until: until.map(at), back: None, label: String::new() };
982        let night = crate::needs::Kept { key: "sleep".into(), kind: "sleep", index: 0, name: String::new(), start: now - 3600, at: now, end: now + 7 * 3600, notices: false };
983        let blocks = Blocks { kept: vec![night], sleep: true, night: true, ..Blocks::default() };
984        assert_eq!(wait_of(&mode(Time::Sleep, Reason::Sleep, Some("2026-10-08T06:30")), &blocks, now), Wait::Sleep { from: now - 3600, until: now + 7 * 3600 });
985        assert_eq!(wait_of(&mode(Time::Sleep, Reason::Paused, None), &blocks, now), Wait::Paused, "the pause first: its time is sleep's too");
986        assert_eq!(wait_of(&mode(Time::Leisure, Reason::FreeTime, Some("2026-10-07T23:45")), &Blocks::default(), now), Wait::Free { until: now + 15 * 60 });
987        assert_eq!(wait_of(&mode(Time::Leisure, Reason::Evening, None), &Blocks::default(), now), Wait::Work);
988        assert_eq!(wait_of(&Mode { quiet: false, ..mode(Time::Work, Reason::Working, None) }, &Blocks::default(), now), Wait::Nothing);
989        // As the matrix reads it, from the configuration: the sleep's block, Free time's end, the hours' end.
990        let config = Config::default();
991        let asleep = Holds::of(&config, &mode(Time::Sleep, Reason::Sleep, Some("2026-10-08T06:30")), &blocks, now);
992        assert_eq!((asleep.now.times.clone(), asleep.now.span, asleep.until), (vec![attention::Column::Sleep], (now - 3600, now + 7 * 3600), Some(now + 7 * 3600)));
993        let free = Holds::of(&config, &mode(Time::Leisure, Reason::FreeTime, Some("2026-10-07T23:45")), &Blocks::default(), now);
994        assert_eq!((free.now.times.clone(), free.now.span.1, free.until), (vec![attention::Column::Free], now + 15 * 60, Some(now + 15 * 60)));
995        let paused = Holds::of(&config, &mode(Time::Sleep, Reason::Paused, None), &blocks, now);
996        assert_eq!((paused.now.times.clone(), paused.until), (vec![attention::Column::Pause], None));
997        let evening = Holds::of(&config, &mode(Time::Leisure, Reason::Evening, Some("2026-10-08T09:00")), &Blocks::default(), now);
998        assert_eq!((evening.wait, evening.until), (Wait::Work, Some(at("2026-10-08T09:00").timestamp().as_second())));
999    }
1000
1001    /// Reminders of each kind, due at `at` for an event (or a date) at `starts`.
1002    fn each_kind(at: i64, starts: i64) -> Vec<Reminder> {
1003        [Kind::Before, Kind::Alarm, Kind::Event, Kind::Asked, Kind::Wait, Kind::Payment, Kind::Paper, Kind::Contract, Kind::Money]
1004            .into_iter()
1005            .flat_map(|kind| [false, true].map(move |work| Reminder { key: format!("{kind:?}:{work}"), kind, at, until: starts + 60, title: String::new(), body: String::new(), target: String::new(), work, starts: Some(starts) }))
1006            .collect()
1007    }
1008
1009    #[test]
1010    fn the_usual_matrix_holds_as_before() {
1011        // The rule as it was before the matrix, word for word.
1012        let before = |r: &Reminder, now: i64, wait: Wait| {
1013            let own = matches!(r.kind, Kind::Alarm | Kind::Before);
1014            let falls_in = |from: i64, until: i64| own && r.starts.is_some_and(|s| from <= s && s < until);
1015            let held = match wait {
1016                Wait::Nothing => false,
1017                Wait::Work => r.work,
1018                Wait::Sleep { from, until } => !falls_in(from, until),
1019                Wait::Paused => !own,
1020                Wait::Free { until } => !falls_in(i64::MIN, until),
1021            };
1022            r.at <= now && now < r.until && !held
1023        };
1024        let now = at("2026-10-07T23:30").timestamp().as_second();
1025        let waits = [Wait::Nothing, Wait::Work, Wait::Sleep { from: now - 3600, until: now + 3600 }, Wait::Paused, Wait::Free { until: now + 1800 }];
1026        for starts in [now + 600, now + 2 * 3600] {
1027            for r in each_kind(now - 60, starts) {
1028                for wait in waits {
1029                    assert_eq!(r.ready(now, wait), before(&r, now, wait), "{:?} {wait:?} {starts}", r.key);
1030                }
1031            }
1032        }
1033    }
1034
1035    #[test]
1036    fn the_matrix_changes_what_waits() {
1037        let now = at("2026-10-07T23:30").timestamp().as_second();
1038        let night = Wait::Sleep { from: now - 3600, until: now + 7 * 3600 };
1039        let after_waking = now + 8 * 3600;
1040        let train = Reminder { key: "before:train".into(), kind: Kind::Before, at: now - 60, until: after_waking, title: String::new(), body: String::new(), target: String::new(), work: false, starts: Some(after_waking) };
1041        let dinner = Reminder { key: "alarm:dinner".into(), kind: Kind::Alarm, starts: Some(now + 600), until: now + 600, ..train.clone() };
1042        let bill = Reminder { key: "payment:water".into(), kind: Kind::Payment, until: now + 86_400, starts: None, ..train.clone() };
1043        let mut holds = Holds::usual(night);
1044        assert!(!train.ready_in(now, &holds) && !bill.ready_in(now, &holds), "as usual: both wait for waking");
1045        // Reminders before an event come during sleep, whenever the event; the working day's dates too.
1046        holds.attention.set(attention::Row::Own(attention::Kind::Before), attention::Column::Sleep, Level::Now).unwrap();
1047        holds.attention.set(attention::Row::Own(attention::Kind::Dates), attention::Column::Sleep, Level::Now).unwrap();
1048        assert!(train.ready_in(now, &holds) && bill.ready_in(now, &holds));
1049        // Reminders before an event wait during a pause; the alarms you set still come there.
1050        let mut paused = Holds::usual(Wait::Paused);
1051        paused.attention.set(attention::Row::Own(attention::Kind::Before), attention::Column::Pause, Level::Later).unwrap();
1052        assert!(!train.ready_in(now, &paused) && dinner.ready_in(now, &paused));
1053        // An event's alarms during sleep, waiting for waking whatever the event.
1054        let mut asleep = Holds::usual(night);
1055        assert!(dinner.ready_in(now, &asleep), "as usual: its event falls in the night");
1056        asleep.attention.set(attention::Row::Own(attention::Kind::Alarms), attention::Column::Sleep, Level::Later).unwrap();
1057        assert!(!dinner.ready_in(now, &asleep));
1058        // During do-not-disturb: the working day before waits when you say so; work resting still holds work's.
1059        let mut working = Holds::usual(Wait::Nothing);
1060        let eve = Reminder { key: "event:board".into(), kind: Kind::Event, starts: None, ..bill.clone() };
1061        working.now.dnd = true;
1062        assert!(eve.ready_in(now, &working));
1063        working.attention.set(attention::Row::Own(attention::Kind::DayBefore), attention::Column::Dnd, Level::Later).unwrap();
1064        assert!(!eve.ready_in(now, &working) && bill.ready_in(now, &working));
1065        let mut evening = Holds::usual(Wait::Work);
1066        evening.attention.set(attention::Row::Own(attention::Kind::Dates), attention::Column::Leisure, Level::Later).unwrap();
1067        let personal = Reminder { key: "asked:garden".into(), kind: Kind::Asked, ..bill.clone() };
1068        let work = Reminder { key: "asked:report".into(), work: true, ..personal.clone() };
1069        assert!(!personal.ready_in(now, &evening) && !work.ready_in(now, &evening));
1070        assert!(personal.ready(now, Wait::Work) && !work.ready(now, Wait::Work), "as usual, work's waits for work");
1071    }
1072}