1use 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
47pub const REMIND: &str = "X-SIOUL-REMIND";
52
53pub const LEADS: [u32; 7] = [0, 5, 10, 15, 30, 60, 120];
55
56const SAME_MOMENT: i64 = 5 * 60;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Remind {
62 Usual,
64 Never,
66 Minutes(u32),
68}
69
70impl Remind {
71 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 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 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
107pub 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
114fn 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
134fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
141#[serde(rename_all = "lowercase")]
142pub enum Kind {
143 Event,
145 Before,
147 Alarm,
149 Asked,
151 Wait,
153 Payment,
155 Paper,
157 Contract,
159 Money,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub struct Reminder {
166 pub key: String,
168 pub kind: Kind,
169 pub at: i64,
171 pub until: i64,
173 pub title: String,
174 pub body: String,
175 pub target: String,
177 pub work: bool,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub starts: Option<i64>,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum Wait {
187 Nothing,
188 Work,
190 Sleep { from: i64, until: i64 },
194 Paused,
198 Free { until: i64 },
201}
202
203#[derive(Debug, Clone, PartialEq)]
208pub struct Holds {
209 pub wait: Wait,
210 pub attention: Attention,
211 pub now: attention::Now,
216 pub until: Option<i64>,
218}
219
220impl Holds {
221 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 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 pub fn level(&self, kind: Kind) -> Level {
254 self.attention.level(attention::Row::Own(row_of(kind)), &self.now)
255 }
256}
257
258fn 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 pub fn ready(&self, now: i64, wait: Wait) -> bool {
271 self.ready_in(now, &Holds::usual(wait))
272 }
273
274 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
285fn 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
295fn 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
311fn 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
325fn 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
333fn 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
348pub 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
365pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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
615pub 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 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
636pub 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
650pub 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
669pub 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
678pub 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
691pub fn told(dir: &Path, key: &str) -> bool {
693 dir.join(format!("{:016x}", fnv(key))).exists()
694}
695
696pub 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
706pub 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"); let events = vec![event("dentist", "2026-10-12T09:00", vec![-3600])];
767 let mut tasks = vec![task("form", "2026-10-08")];
769 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 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 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 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 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 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 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 let mut late = task("late", "2026-10-07T12:00");
833 late.created = now.timestamp().as_second();
834 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 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 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 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"); let stamp = |text: &str| at(text).timestamp().as_second();
866 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}