Skip to main content

max / goingson

6.9 KB · 184 lines History Blame Raw
1 //! What GoingsOn can interrupt you about, declared once.
2 //!
3 //! The registry [`quasi_notifs`] is built around: three kinds, each carrying
4 //! everything the settings pane, the config store and the watcher need.
5 //!
6 //! # Why this module is not inside `notifications`
7 //!
8 //! That module is desktop-only — `#[cfg(not(any(target_os = "ios", target_os =
9 //! "android")))]` — because delivery is. The declaration is not: the generated
10 //! keys are part of [`crate::config_key::CONFIG`] on every platform, or a phone
11 //! would sync a table whose closed set it disagreed with, and refuse the rows
12 //! the desktop wrote.
13 //!
14 //! # Which kinds ship on
15 //!
16 //! The first three declare [`shipping_on`](Kind::shipping_on). **A new kind
17 //! ships off**, and onboarding points at it: the pointer counts the kinds that
18 //! ship off and that nobody has answered yet, over
19 //! [`crate::commands::list_notification_kinds`].
20 //!
21 //! Those three carry no [`Knob`](quasi_notifs::Knob), and that is measured
22 //! rather than deferred: the one lead time in this app belongs to the *event*
23 //! (`event.reminder_offsets_seconds`, several per event), which is the exact
24 //! case `Setting`'s header says is not a knob, and the other lead time in the
25 //! settings pane is `event_lead_minutes`, which is not about notifications at
26 //! all. See [`crate::config_key`] for the collision that dotted keys settle.
27 //!
28 //! # The fourth, which needed a scheduler
29 //!
30 //! **Task due-date reminders** needs a pass of its own in the watcher and a
31 //! lead time to compare against. It ships off, and it carries the one
32 //! [`Knob`](quasi_notifs::Knob) GoingsOn declares.
33 //!
34 //! <!-- wiki: quasi-overview -->
35
36 use quasi_notifs::{Kind, Knob, Registry, Setting};
37
38 /// The snooze-expiry kind's id, and the token stem its occurrences use.
39 pub const SNOOZE_EXPIRY: &str = "snooze-expiry";
40 /// The overdue-response kind's id.
41 pub const RESPONSE_OVERDUE: &str = "response-overdue";
42 /// The event-reminder kind's id.
43 pub const EVENT_REMINDER: &str = "event-reminder";
44 /// The task-due kind's id.
45 pub const TASK_DUE: &str = "task-due";
46 /// The task-due kind's lead-time knob.
47 pub const TASK_DUE_LEAD: &str = "lead";
48
49 /// How long before a task is due it says so, before anyone chooses.
50 ///
51 /// An hour. A task's `due` is a full instant rather than a date (the form
52 /// parses "friday 3pm"), so a lead measured in days would fire at a time that
53 /// has nothing to do with the deadline, and one measured in minutes is a
54 /// reminder you cannot act on. An hour is long enough to do something and short
55 /// enough to still be about today.
56 const TASK_DUE_LEAD_DEFAULT_SECONDS: i64 = 60 * 60;
57
58 /// The task-due kind's knobs. One: how far ahead it speaks up.
59 static TASK_DUE_KNOBS: &[Knob] = &[Knob::new(
60 TASK_DUE_LEAD,
61 "Lead time (seconds)",
62 Setting::Seconds(TASK_DUE_LEAD_DEFAULT_SECONDS),
63 )];
64
65 /// Every kind GoingsOn declares.
66 ///
67 /// One category, because all three are the same promise: something you already
68 /// asked to hear about, at the moment it comes due. A second category would be
69 /// a second heading in the pane over one control, which reads as structure that
70 /// is not there.
71 static KINDS: &[Kind] = &[
72 Kind::new(
73 SNOOZE_EXPIRY,
74 "Snoozed items resurface",
75 "When a task or email you snoozed comes back.",
76 "Reminders",
77 )
78 .shipping_on(),
79 Kind::new(
80 RESPONSE_OVERDUE,
81 "Replies you are still waiting for",
82 "When something you marked as waiting passes the date you expected it.",
83 "Reminders",
84 )
85 .shipping_on(),
86 // The one kind that is quiet after a restart, and the reason `CatchUp` is
87 // per kind rather than per app: a snooze that ran out overnight is still
88 // wanted, and a reminder for a meeting that started an hour ago is an
89 // interruption about something already missed. The shipped watcher said
90 // this with a `reminders_bootstrapped` flag it had to reset by hand
91 // whenever it cleared its memory; the declaration says it once.
92 Kind::new(
93 EVENT_REMINDER,
94 "Event reminders",
95 "Before an event starts, at the lead times that event carries.",
96 "Reminders",
97 )
98 .shipping_on()
99 .quiet_after_restart(),
100 // The one kind that ships off, because it is new and every new kind does.
101 // It takes the default `CatchUp::Fire` rather than `quiet_after_restart`,
102 // and the contrast with the kind above it is the whole of what `CatchUp`
103 // is for: a meeting that started while the app was shut is a moment that
104 // has passed, and a deadline that passed while the app was shut is still
105 // a deadline.
106 Kind::new(
107 TASK_DUE,
108 "Tasks coming due",
109 "Before a task's due date, at the lead time you choose.",
110 "Reminders",
111 )
112 .with(TASK_DUE_KNOBS),
113 ];
114
115 /// GoingsOn's notification registry.
116 pub static NOTIFS: Registry = Registry::new(KINDS);
117
118 #[cfg(test)]
119 mod tests {
120 use super::*;
121 use quasi_notifs::config::Unset;
122
123 #[test]
124 fn the_registry_is_well_formed() {
125 // Everything a `const` cannot check: duplicate ids, key shape, and a
126 // choice whose default is not one of its options.
127 NOTIFS.check().expect("the registry is well formed");
128 }
129
130 #[test]
131 fn every_kind_that_fired_before_the_registry_still_ships_on() {
132 // Adoption must not silently stop a notification that fires today.
133 // Scoped to the three the adoption grandfathered rather than to the
134 // whole registry, because a new kind ships off and this test used to
135 // say the opposite.
136 for id in [SNOOZE_EXPIRY, RESPONSE_OVERDUE, EVENT_REMINDER] {
137 assert!(
138 NOTIFS.is_on(id, &Unset),
139 "{id} fired before the registry existed and must ship on"
140 );
141 }
142 }
143
144 #[test]
145 fn the_task_due_kind_ships_off() {
146 // Max, 2026-08-17: new kinds ship off, and onboarding points at them.
147 assert!(!NOTIFS.is_on(TASK_DUE, &Unset), "a new kind ships off");
148 }
149
150 #[test]
151 fn the_lead_time_defaults_to_an_hour() {
152 let lead = NOTIFS
153 .value(TASK_DUE, Some(TASK_DUE_LEAD), &Unset)
154 .expect("the knob is declared");
155 assert_eq!(lead.number(), Some(TASK_DUE_LEAD_DEFAULT_SECONDS));
156 }
157
158 #[test]
159 fn only_the_event_reminder_is_quiet_after_a_restart() {
160 use quasi_notifs::CatchUp;
161
162 let quiet: Vec<&str> = NOTIFS
163 .kinds()
164 .iter()
165 .filter(|kind| kind.restart == CatchUp::Skip)
166 .map(|kind| kind.id)
167 .collect();
168 assert_eq!(quiet, vec![EVENT_REMINDER]);
169 }
170
171 #[test]
172 fn generated_keys_are_dotted() {
173 // The whole of what keeps `event-reminder.lead` -- if one is ever
174 // declared -- apart from `event_lead_minutes` in one table.
175 for generated in NOTIFS.config() {
176 assert!(
177 generated.key.contains('.'),
178 "{} is not dotted and could collide with an app key",
179 generated.key
180 );
181 }
182 }
183 }
184