//! What GoingsOn can interrupt you about, declared once. //! //! The registry [`quasi_notifs`] is built around: three kinds, each carrying //! everything the settings pane, the config store and the watcher need. //! //! # Why this module is not inside `notifications` //! //! That module is desktop-only — `#[cfg(not(any(target_os = "ios", target_os = //! "android")))]` — because delivery is. The declaration is not: the generated //! keys are part of [`crate::config_key::CONFIG`] on every platform, or a phone //! would sync a table whose closed set it disagreed with, and refuse the rows //! the desktop wrote. //! //! # Which kinds ship on //! //! The first three declare [`shipping_on`](Kind::shipping_on). **A new kind //! ships off**, and onboarding points at it: the pointer counts the kinds that //! ship off and that nobody has answered yet, over //! [`crate::commands::list_notification_kinds`]. //! //! Those three carry no [`Knob`](quasi_notifs::Knob), and that is measured //! rather than deferred: the one lead time in this app belongs to the *event* //! (`event.reminder_offsets_seconds`, several per event), which is the exact //! case `Setting`'s header says is not a knob, and the other lead time in the //! settings pane is `event_lead_minutes`, which is not about notifications at //! all. See [`crate::config_key`] for the collision that dotted keys settle. //! //! # The fourth, which needed a scheduler //! //! **Task due-date reminders** needs a pass of its own in the watcher and a //! lead time to compare against. It ships off, and it carries the one //! [`Knob`](quasi_notifs::Knob) GoingsOn declares. //! //! use quasi_notifs::{Kind, Knob, Registry, Setting}; /// The snooze-expiry kind's id, and the token stem its occurrences use. pub const SNOOZE_EXPIRY: &str = "snooze-expiry"; /// The overdue-response kind's id. pub const RESPONSE_OVERDUE: &str = "response-overdue"; /// The event-reminder kind's id. pub const EVENT_REMINDER: &str = "event-reminder"; /// The task-due kind's id. pub const TASK_DUE: &str = "task-due"; /// The task-due kind's lead-time knob. pub const TASK_DUE_LEAD: &str = "lead"; /// How long before a task is due it says so, before anyone chooses. /// /// An hour. A task's `due` is a full instant rather than a date (the form /// parses "friday 3pm"), so a lead measured in days would fire at a time that /// has nothing to do with the deadline, and one measured in minutes is a /// reminder you cannot act on. An hour is long enough to do something and short /// enough to still be about today. const TASK_DUE_LEAD_DEFAULT_SECONDS: i64 = 60 * 60; /// The task-due kind's knobs. One: how far ahead it speaks up. static TASK_DUE_KNOBS: &[Knob] = &[Knob::new( TASK_DUE_LEAD, "Lead time (seconds)", Setting::Seconds(TASK_DUE_LEAD_DEFAULT_SECONDS), )]; /// Every kind GoingsOn declares. /// /// One category, because all three are the same promise: something you already /// asked to hear about, at the moment it comes due. A second category would be /// a second heading in the pane over one control, which reads as structure that /// is not there. static KINDS: &[Kind] = &[ Kind::new( SNOOZE_EXPIRY, "Snoozed items resurface", "When a task or email you snoozed comes back.", "Reminders", ) .shipping_on(), Kind::new( RESPONSE_OVERDUE, "Replies you are still waiting for", "When something you marked as waiting passes the date you expected it.", "Reminders", ) .shipping_on(), // The one kind that is quiet after a restart, and the reason `CatchUp` is // per kind rather than per app: a snooze that ran out overnight is still // wanted, and a reminder for a meeting that started an hour ago is an // interruption about something already missed. The shipped watcher said // this with a `reminders_bootstrapped` flag it had to reset by hand // whenever it cleared its memory; the declaration says it once. Kind::new( EVENT_REMINDER, "Event reminders", "Before an event starts, at the lead times that event carries.", "Reminders", ) .shipping_on() .quiet_after_restart(), // The one kind that ships off, because it is new and every new kind does. // It takes the default `CatchUp::Fire` rather than `quiet_after_restart`, // and the contrast with the kind above it is the whole of what `CatchUp` // is for: a meeting that started while the app was shut is a moment that // has passed, and a deadline that passed while the app was shut is still // a deadline. Kind::new( TASK_DUE, "Tasks coming due", "Before a task's due date, at the lead time you choose.", "Reminders", ) .with(TASK_DUE_KNOBS), ]; /// GoingsOn's notification registry. pub static NOTIFS: Registry = Registry::new(KINDS); #[cfg(test)] mod tests { use super::*; use quasi_notifs::config::Unset; #[test] fn the_registry_is_well_formed() { // Everything a `const` cannot check: duplicate ids, key shape, and a // choice whose default is not one of its options. NOTIFS.check().expect("the registry is well formed"); } #[test] fn every_kind_that_fired_before_the_registry_still_ships_on() { // Adoption must not silently stop a notification that fires today. // Scoped to the three the adoption grandfathered rather than to the // whole registry, because a new kind ships off and this test used to // say the opposite. for id in [SNOOZE_EXPIRY, RESPONSE_OVERDUE, EVENT_REMINDER] { assert!( NOTIFS.is_on(id, &Unset), "{id} fired before the registry existed and must ship on" ); } } #[test] fn the_task_due_kind_ships_off() { // Max, 2026-08-17: new kinds ship off, and onboarding points at them. assert!(!NOTIFS.is_on(TASK_DUE, &Unset), "a new kind ships off"); } #[test] fn the_lead_time_defaults_to_an_hour() { let lead = NOTIFS .value(TASK_DUE, Some(TASK_DUE_LEAD), &Unset) .expect("the knob is declared"); assert_eq!(lead.number(), Some(TASK_DUE_LEAD_DEFAULT_SECONDS)); } #[test] fn only_the_event_reminder_is_quiet_after_a_restart() { use quasi_notifs::CatchUp; let quiet: Vec<&str> = NOTIFS .kinds() .iter() .filter(|kind| kind.restart == CatchUp::Skip) .map(|kind| kind.id) .collect(); assert_eq!(quiet, vec![EVENT_REMINDER]); } #[test] fn generated_keys_are_dotted() { // The whole of what keeps `event-reminder.lead` -- if one is ever // declared -- apart from `event_lead_minutes` in one table. for generated in NOTIFS.config() { assert!( generated.key.contains('.'), "{} is not dotted and could collide with an app key", generated.key ); } } }