Skip to main content

max / goingson

goingson adopts quasi-notifs: three kinds declared, one pane generated The watcher's four framework behaviours are the Outbox's now: consulting whether a kind is on, suppressing a duplicate by token, staying quiet about what came due while the app was shut, and bounding the memory. NotifiedItems and the reminders_bootstrapped flag go with them, along with the rule that clearing a set had to reset the bootstrap or the next pass fired everything the set was holding back. Two behaviours changed, both fixes. The two task kinds no longer share one notified set, so an item can resurface from a snooze and also tell you its reply is late. And a snooze that runs out now unsnoozes whether or not the notification was delivered, since expiring is a fact about the data and there is a switch now that there was not before. The settings pane is generated from the registry, so adding a kind adds its settings. event_lead_minutes stays hand-written beside it under its own heading: it colours a dot on the Events tab and is not a delivery setting, which is the collision the dotted generated keys settle in the store and the hint settles in words. The generated keys join config_key's spec rather than sitting in a second one beside it -- one table has one policy seeding, one trigger pair and one ensure_known, and a key declared in the other half would read as undeclared and fail closed into "your preference does not sync". Not adopted: quasi-tauri's Notifier, because that crate depends on quasi-router unconditionally and taking eight lines of delivery would pull the description stack into every build. The eight lines are here meanwhile.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-18 22:05 UTC
Signed with PGP, not checked
Commit: ee127baf155a9a032c1edcf87349ade295b4ccd6
Parent: 7ee0a1c
8 files changed, +656 insertions, -180 deletions
M Cargo.lock +32 -23
@@ -1387,7 +1387,7 @@
1387 1387 "libc",
1388 1388 "option-ext",
1389 1389 "redox_users",
1390 - "windows-sys 0.61.2",
1390 + "windows-sys 0.60.2",
1391 1391 ]
1392 1392
1393 1393 [[package]]
@@ -1628,7 +1628,7 @@
1628 1628 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1629 1629 dependencies = [
1630 1630 "libc",
1631 - "windows-sys 0.61.2",
1631 + "windows-sys 0.60.2",
1632 1632 ]
1633 1633
1634 1634 [[package]]
@@ -2273,6 +2273,7 @@
2273 2273 "painhours",
2274 2274 "pter",
2275 2275 "quasi-http",
2276 + "quasi-notifs",
2276 2277 "quasi-router",
2277 2278 "quasi-tauri",
2278 2279 "quasi-type",
@@ -3628,7 +3629,7 @@
3628 3629 "png 0.18.1",
3629 3630 "serde",
3630 3631 "thiserror 2.0.20",
3631 - "windows-sys 0.61.2",
3632 + "windows-sys 0.60.2",
3632 3633 ]
3633 3634
3634 3635 [[package]]
@@ -3761,7 +3762,7 @@
3761 3762 source = "registry+https://github.com/rust-lang/crates.io-index"
3762 3763 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3763 3764 dependencies = [
3764 - "windows-sys 0.61.2",
3765 + "windows-sys 0.60.2",
3765 3766 ]
3766 3767
3767 3768 [[package]]
@@ -4695,23 +4696,31 @@
4695 4696
4696 4697 [[package]]
4697 4698 name = "quasi-http"
4698 - version = "0.29.0"
4699 + version = "0.29.1"
4699 4700 dependencies = [
4700 4701 "form_urlencoded",
4701 4702 "http",
4702 4703 "quasi-router",
4703 4704 ]
4704 4705
4706 + [[package]]
4707 + name = "quasi-notifs"
4708 + version = "0.29.1"
4709 + dependencies = [
4710 + "quasi-router",
4711 + "synckit-config",
4712 + ]
4713 +
4705 4714 [[package]]
4706 4715 name = "quasi-router"
4707 - version = "0.29.0"
4716 + version = "0.29.1"
4708 4717 dependencies = [
4709 4718 "makeover-layout",
4710 4719 ]
4711 4720
4712 4721 [[package]]
4713 4722 name = "quasi-tauri"
4714 - version = "0.29.0"
4723 + version = "0.29.1"
4715 4724 dependencies = [
4716 4725 "http",
4717 4726 "quasi-http",
@@ -4737,7 +4746,7 @@
4737 4746
4738 4747 [[package]]
4739 4748 name = "quasi-webview"
4740 - version = "0.29.0"
4749 + version = "0.29.1"
4741 4750 dependencies = [
4742 4751 "docengine",
4743 4752 "makeover-layout",
@@ -5176,7 +5185,7 @@
5176 5185 "errno",
5177 5186 "libc",
5178 5187 "linux-raw-sys",
5179 - "windows-sys 0.61.2",
5188 + "windows-sys 0.60.2",
5180 5189 ]
5181 5190
5182 5191 [[package]]
@@ -5233,7 +5242,7 @@
5233 5242 "security-framework",
5234 5243 "security-framework-sys",
5235 5244 "webpki-root-certs",
5236 - "windows-sys 0.61.2",
5245 + "windows-sys 0.60.2",
5237 5246 ]
5238 5247
5239 5248 [[package]]
@@ -5773,7 +5782,7 @@
5773 5782 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
5774 5783 dependencies = [
5775 5784 "libc",
5776 - "windows-sys 0.61.2",
5785 + "windows-sys 0.60.2",
5777 5786 ]
5778 5787
5779 5788 [[package]]
@@ -6548,7 +6557,7 @@
6548 6557 "getrandom 0.4.3",
6549 6558 "once_cell",
6550 6559 "rustix",
6551 - "windows-sys 0.61.2",
6560 + "windows-sys 0.60.2",
6552 6561 ]
6553 6562
6554 6563 [[package]]
@@ -6570,7 +6579,7 @@
6570 6579 "parking_lot",
6571 6580 "rustix",
6572 6581 "signal-hook",
6573 - "windows-sys 0.61.2",
6582 + "windows-sys 0.60.2",
6574 6583 ]
6575 6584
6576 6585 [[package]]
@@ -7052,7 +7061,7 @@
7052 7061 "png 0.18.1",
7053 7062 "serde",
7054 7063 "thiserror 2.0.20",
7055 - "windows-sys 0.61.2",
7064 + "windows-sys 0.60.2",
7056 7065 ]
7057 7066
7058 7067 [[package]]
@@ -7087,7 +7096,7 @@
7087 7096 dependencies = [
7088 7097 "memoffset",
7089 7098 "tempfile",
7090 - "windows-sys 0.61.2",
7099 + "windows-sys 0.60.2",
7091 7100 ]
7092 7101
7093 7102 [[package]]
@@ -7617,7 +7626,7 @@
7617 7626 source = "registry+https://github.com/rust-lang/crates.io-index"
7618 7627 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
7619 7628 dependencies = [
7620 - "windows-sys 0.61.2",
7629 + "windows-sys 0.60.2",
7621 7630 ]
7622 7631
7623 7632 [[package]]
@@ -8487,25 +8496,25 @@
8487 8496 "winnow 1.0.4",
8488 8497 ]
8489 8498
8490 - [[patch.unused]]
8491 - name = "ops-status"
8492 - version = "0.1.0"
8493 -
8494 8499 [[patch.unused]]
8495 8500 name = "makeover-immediate"
8496 8501 version = "0.30.0"
8497 8502
8503 + [[patch.unused]]
8504 + name = "ops-status"
8505 + version = "0.1.0"
8506 +
8498 8507 [[patch.unused]]
8499 8508 name = "quasi-axum"
8500 - version = "0.29.0"
8509 + version = "0.29.1"
8501 8510
8502 8511 [[patch.unused]]
8503 8512 name = "quasi-basics"
8504 - version = "0.29.0"
8513 + version = "0.29.1"
8505 8514
8506 8515 [[patch.unused]]
8507 8516 name = "quasi-immediate"
8508 - version = "0.29.0"
8517 + version = "0.29.1"
8509 8518
8510 8519 [[patch.unused]]
8511 8520 name = "quasi-store"
@@ -61,6 +61,23 @@
61 61 quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.29", optional = true }
62 62 quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.29", optional = true }
63 63
64 + # The notification declaration, and it is NOT behind the `quasi` feature: the
65 + # watcher is desktop app behaviour rather than a described screen, and the
66 + # generated config keys are part of `config_key::CONFIG` on every platform. The
67 + # crate is dep-free at its core for exactly this case; `synckit` adds the spec
68 + # it produces, and `describe` (below, under `quasi`) adds the settings pane.
69 + #
70 + # NOT `quasi-tauri`'s `notifs` feature, which is where the delivery adapter
71 + # lives. That crate depends on `quasi-router` unconditionally, so taking its
72 + # `Notifier` -- eight lines over `tauri-plugin-notification`, which this app
73 + # already links -- would pull the whole description stack into a default build
74 + # and undo what the `quasi` feature is for. Filed against quasi as a finding:
75 + # the adapter is welded to the router crate, so an app that wants delivery
76 + # before it has ported its screens cannot take it. `notifications::Notifier` is
77 + # the eight lines meanwhile, and it goes when the flip makes quasi-router
78 + # unconditional here.
79 + quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.29", features = ["synckit"] }
80 +
64 81 # Tauri
65 82 tauri = { workspace = true, features = ["image-png", "devtools"] }
66 83 tauri-plugin-dialog = { workspace = true }
@@ -209,7 +226,7 @@
209 226 # Three so far, projects, contacts and tasks. Turning this on by default is
210 227 # what starts retiring escape.js, and it is UX-visible, so it is a deliberate
211 228 # step rather than a consequence of adding a screen.
212 - quasi = ["dep:quasi-router", "dep:quasi-http", "dep:quasi-webview", "dep:quasi-tauri"]
229 + quasi = ["dep:quasi-router", "dep:quasi-http", "dep:quasi-webview", "dep:quasi-tauri", "quasi-notifs/describe"]
213 230
214 231 [lints]
215 232 workspace = true
@@ -21,8 +21,15 @@
21 21 //!
22 22 //! <!-- wiki: go-config -->
23 23
24 + use std::sync::LazyLock;
25 +
24 26 use synckit_config::{ConfigSpec, Posture};
25 27
28 + use crate::notifs::NOTIFS;
29 +
30 + /// The table every one of these keys lives in.
31 + const TABLE: &str = "user_config";
32 +
26 33 /// GoingsOn's `user_config` posture declaration.
27 34 ///
28 35 /// Preferences worth carrying across a user's devices are `Synced`; per-device
@@ -32,8 +39,8 @@
32 39 /// dismissal) and `hint_dayplan` (the first-visit day-plan walkthrough) are
33 40 /// per-device: the gestures those teach differ between a desktop and a tablet,
34 41 /// so having seen them on one device says nothing about the next.
35 - pub const CONFIG: ConfigSpec = ConfigSpec::new(
36 - "user_config",
42 + const APP_KEYS: ConfigSpec = ConfigSpec::new(
43 + TABLE,
37 44 &[
38 45 // Synced: preferences carried across the user's devices.
39 46 ("theme", Posture::Synced),
@@ -58,6 +65,38 @@
58 65 ],
59 66 );
60 67
68 + /// Every `user_config` key: the ones written above, plus the ones GoingsOn's
69 + /// notification kinds generate.
70 + ///
71 + /// One spec over one table, rather than two beside each other. The table has a
72 + /// single `config_key_policy` seeding
73 + /// ([`syncstore::seed_config_policy`](crate::syncstore)), a single
74 + /// engine-generated trigger pair, and a single `ensure_known` refusing writes;
75 + /// two specs would mean each of those three choosing which half to consult, and
76 + /// a key that is declared in the other one reads as undeclared, which is
77 + /// fail-closed straight into "your notification preference does not sync".
78 + ///
79 + /// The generated half comes from [`NOTIFS`] rather than being restated here, so
80 + /// a kind cannot arrive with its keys unclassified and therefore silently
81 + /// `Local`. Its keys are dotted (`snooze-expiry.enabled`) and the hand-written
82 + /// ones are not, which is what keeps a generated lead time from being mistaken
83 + /// for `event_lead_minutes`.
84 + ///
85 + /// # It leaks, once
86 + ///
87 + /// A [`ConfigSpec`] holds `&'static str` because it is meant to be a `const`,
88 + /// and a generated key is a `String`. What leaks is one bounded allocation per
89 + /// generated key, on first use, for a set that is fixed at compile time and
90 + /// would have lived for the process anyway. `LazyLock` is what makes it once:
91 + /// this is read on every config write and on every screen render.
92 + pub static CONFIG: LazyLock<ConfigSpec> = LazyLock::new(|| {
93 + let mut keys: Vec<(&'static str, Posture)> = APP_KEYS.keys().collect();
94 + for (key, posture) in NOTIFS.postures() {
95 + keys.push((Box::leak(key.into_boxed_str()), posture));
96 + }
97 + ConfigSpec::new(TABLE, Box::leak(keys.into_boxed_slice()))
98 + });
99 +
61 100 #[cfg(test)]
62 101 mod tests {
63 102 use super::*;
@@ -107,9 +146,45 @@
107 146 "review_nudges",
108 147 "work_start_hour",
109 148 "work_end_hour",
149 + // The generated half. A notification preference is a preference, so
150 + // `Reach::Synced` is quasi-notifs' default and nothing here asks
151 + // for anything else. Listed rather than derived from the registry:
152 + // this test exists to notice a key appearing, and a test that
153 + // regenerates its own expectation notices nothing.
154 + "snooze-expiry.enabled",
155 + "response-overdue.enabled",
156 + "event-reminder.enabled",
110 157 ]
111 158 .into_iter()
112 159 .collect();
113 160 assert_eq!(replicated, expected);
114 161 }
162 +
163 + /// Every key a notification kind generates is declared here.
164 + ///
165 + /// The failure this catches is the quiet one: an undeclared key is `Local`
166 + /// (fail-closed), so a kind whose keys never reached the spec would work
167 + /// perfectly on one machine and never travel, and nothing else would say
168 + /// so.
169 + #[test]
170 + fn every_generated_notification_key_is_declared() {
171 + for generated in NOTIFS.config() {
172 + assert!(
173 + CONFIG.keys().any(|(known, _)| known == generated.key),
174 + "{} is generated but not declared",
175 + generated.key
176 + );
177 + }
178 + }
179 +
180 + /// The dotted keys and the hand-written ones cannot collide.
181 + #[test]
182 + fn no_generated_key_shadows_an_app_key() {
183 + for (key, _) in APP_KEYS.keys() {
184 + assert!(
185 + !key.contains('.'),
186 + "{key} is hand-written and must not look generated"
187 + );
188 + }
189 + }
115 190 }
@@ -13,6 +13,7 @@
13 13 pub mod export;
14 14 pub mod external_sync;
15 15 pub mod jmap;
16 + pub mod notifs;
16 17 pub mod oauth;
17 18 pub mod problems;
18 19 /// The projects screen described rather than built. Off by default; see the
@@ -31,7 +32,8 @@
31 32 #[cfg(not(any(target_os = "ios", target_os = "android")))]
32 33 pub mod db_watcher;
33 34
34 - // Desktop-only: OS notifications for snooze expiry
35 + // Desktop-only: delivering the kinds `notifs` declares. The declaration itself
36 + // is not desktop-only -- see that module's header.
35 37 #[cfg(not(any(target_os = "ios", target_os = "android")))]
36 38 pub mod notifications;
37 39
@@ -1,12 +1,43 @@
1 - //! Background notification system for snooze expiry alerts.
1 + //! Delivering the notification kinds [`crate::notifs`] declares.
2 2 //!
3 - //! Periodically checks for tasks and emails that have expired snooze dates
4 - //! and sends OS notifications when they resurface.
3 + //! A tick, a pass per kind, and one call to the host. Everything that is not
4 + //! about GoingsOn -- consulting whether a kind is on, suppressing a duplicate,
5 + //! not spamming after a restart, bounding the memory -- belongs to
6 + //! [`Outbox`](quasi_notifs::Outbox), which is where it was tested against no
7 + //! host at all. This file went from 483 lines to what is left once those four
8 + //! behaviours are somebody else's, which is the whole argument for adopting the
9 + //! framework (task `07830eb5`, 2026-08-18).
10 + //!
11 + //! # What changed in behaviour, and both are fixes rather than drift
12 + //!
13 + //! **The two task kinds no longer share one memory.** `NotifiedItems` held a
14 + //! single `task_ids` set for snooze expiry *and* overdue responses, so a task
15 + //! that resurfaced from a snooze could never also tell you its reply was late.
16 + //! An outbox remembers per kind, because a token only means anything under the
17 + //! kind it was minted for.
18 + //!
19 + //! **A snooze that runs out still runs out with notifications off.** The
20 + //! shipped code unsnoozed the row inside the same branch that notified, so
21 + //! turning the kind off -- which was not possible before, since there was no
22 + //! switch -- would have left the item snoozed forever. Expiring is a fact about
23 + //! the data and the notification is a fact about the reader, so the unsnooze
24 + //! happens either way.
25 + //!
26 + //! # The one thing here that is only here because of a crate boundary
27 + //!
28 + //! [`Notifier`], eight lines. `quasi-tauri` ships exactly this behind its
29 + //! `notifs` feature and taking it would pull `quasi-router` into every build,
30 + //! because that crate depends on the router unconditionally. See the note in
31 + //! `Cargo.toml`; this goes when the flip makes the description stack
32 + //! unconditional.
5 33
34 + use crate::commands::all_config;
35 + use crate::notifs::{EVENT_REMINDER, NOTIFS, RESPONSE_OVERDUE, SNOOZE_EXPIRY};
6 36 use crate::state::{AppState, DESKTOP_USER_ID};
7 37 use chrono::Utc;
8 - use goingson_core::{EmailId, EventId, TaskId};
9 - use std::collections::HashSet;
38 + use quasi_notifs::config::Settings;
39 + use quasi_notifs::{Deliver, Occurrence, Outbox};
40 + use std::collections::HashMap;
10 41 use std::sync::Arc;
11 42 use std::time::Duration;
12 43 use tauri::Manager;
@@ -17,99 +48,108 @@
17 48 /// Check interval for snooze expiry (60 seconds)
18 49 const CHECK_INTERVAL_SECS: u64 = 60;
19 50
20 - /// Tracks which items we've already notified about to avoid duplicates
21 - struct NotifiedItems {
22 - task_ids: HashSet<TaskId>,
23 - email_ids: HashSet<EmailId>,
24 - /// (event_id, offset_seconds) pairs that have already fired their reminder.
25 - /// One entry per offset because an event can have multiple reminders.
26 - event_reminders: HashSet<(EventId, i64)>,
27 - /// On first tick, mark currently-eligible reminders as fired without
28 - /// notifying, so app restarts don't spam old reminders. After the first
29 - /// tick this stays true and the watcher fires reminders normally.
30 - reminders_bootstrapped: bool,
51 + /// Puts an occurrence in front of a person, through Tauri's notification plugin.
52 + ///
53 + /// The host's entire share of delivery. It cannot fail in a way the caller
54 + /// could act on -- the occurrence is already noted as sent, and an outbox that
55 + /// retried would be a queue nobody asked for -- so a refusal is counted and
56 + /// logged rather than returned.
57 + struct Notifier {
58 + app: tauri::AppHandle,
59 + refused: usize,
31 60 }
32 61
33 - impl NotifiedItems {
34 - fn new() -> Self {
35 - Self {
36 - task_ids: HashSet::new(),
37 - email_ids: HashSet::new(),
38 - event_reminders: HashSet::new(),
39 - reminders_bootstrapped: false,
62 + impl Notifier {
63 + const fn new(app: tauri::AppHandle) -> Self {
64 + Self { app, refused: 0 }
65 + }
66 + }
67 +
68 + impl Deliver for Notifier {
69 + fn deliver(&mut self, note: &Occurrence) {
70 + debug!(title = %note.title, body = %note.body, kind = note.kind, "Sending notification");
71 + if let Err(e) = self
72 + .app
73 + .notification()
74 + .builder()
75 + .title(&note.title)
76 + .body(&note.body)
77 + .show()
78 + {
79 + self.refused += 1;
80 + warn!(error = %e, title = %note.title, refused = self.refused, "Failed to send notification");
40 81 }
41 82 }
42 83 }
43 84
44 - /// Starts the background snooze watcher that checks for expired snoozes
45 - /// and sends OS notifications.
85 + /// What the reader has chosen, as the registry reads it.
86 + ///
87 + /// A snapshot per tick rather than a query per kind: every generated key lives
88 + /// in the one `user_config` table this already reads in a single statement, and
89 + /// a settings pane cannot change under a pass that takes 60 seconds to come
90 + /// round again.
91 + struct Chosen(HashMap<String, String>);
92 +
93 + impl Settings for Chosen {
94 + fn get(&self, key: &str) -> Option<String> {
95 + self.0.get(key).cloned()
96 + }
97 + }
98 +
99 + /// Starts the background watcher that delivers every declared kind.
46 100 pub async fn start_snooze_watcher(app: tauri::AppHandle, cancel: CancellationToken) {
47 101 info!(
48 - "Starting snooze watcher (interval: {}s)",
102 + "Starting notification watcher (interval: {}s)",
49 103 CHECK_INTERVAL_SECS
50 104 );
51 - let mut notified = NotifiedItems::new();
105 + let mut outbox = Outbox::new(NOTIFS, Notifier::new(app.clone()));
52 106 let mut interval = tokio::time::interval(Duration::from_secs(CHECK_INTERVAL_SECS));
53 107
54 108 loop {
55 109 tokio::select! {
56 110 () = cancel.cancelled() => {
57 - info!("Snooze watcher shutting down");
111 + info!("Notification watcher shutting down");
58 112 break;
59 113 }
60 114 _ = interval.tick() => {}
61 115 }
62 116
63 117 let Some(state) = app.try_state::<Arc<AppState>>() else {
64 - debug!("App state not available, skipping snooze check");
118 + debug!("App state not available, skipping notification check");
65 119 continue;
66 120 };
67 121
68 - // Check for expired task snoozes
69 - if let Err(e) = check_task_snoozes(&app, &state, &mut notified).await {
122 + // One read of `user_config` per tick, shared by every pass below. A
123 + // failure here is not a reason to skip the tick: an unreadable config
124 + // means every key reads as its default, and every kind's default is on.
125 + let chosen = Chosen(all_config(&state).unwrap_or_else(|e| {
126 + warn!(error = %e, "Could not read notification settings, using defaults");
127 + HashMap::new()
128 + }));
129 +
130 + if let Err(e) = check_task_snoozes(&mut outbox, &state, &chosen).await {
70 131 error!(error = %e, "Error checking task snoozes");
71 132 }
72 133
73 - // Check for expired email snoozes
74 - if let Err(e) = check_email_snoozes(&app, &state, &mut notified) {
134 + if let Err(e) = check_email_snoozes(&mut outbox, &state, &chosen) {
75 135 error!(error = %e, "Error checking email snoozes");
76 136 }
77 137
78 - // Check for overdue waiting responses
79 - if let Err(e) = check_overdue_responses(&app, &state, &mut notified) {
138 + if let Err(e) = check_overdue_responses(&mut outbox, &state, &chosen) {
80 139 error!(error = %e, "Error checking overdue responses");
81 140 }
82 141
83 - // Check for due event reminders
84 - if let Err(e) = check_event_reminders(&app, &state, &mut notified).await {
142 + if let Err(e) = check_event_reminders(&mut outbox, &state, &chosen).await {
85 143 error!(error = %e, "Error checking event reminders");
86 144 }
87 -
88 - // Clean up old notified IDs periodically. The threshold is high (10k)
89 - // because each UUID is only 16 bytes (~160KB total). Clearing too
90 - // aggressively can re-trigger notifications for snoozed items whose
91 - // unsnooze failed.
92 - if notified.task_ids.len() > 10_000 {
93 - debug!("Clearing task notification cache");
94 - notified.task_ids.clear();
95 - }
96 - if notified.email_ids.len() > 10_000 {
97 - debug!("Clearing email notification cache");
98 - notified.email_ids.clear();
99 - }
100 - if notified.event_reminders.len() > 10_000 {
101 - debug!("Clearing event-reminder notification cache");
102 - notified.event_reminders.clear();
103 - notified.reminders_bootstrapped = false;
104 - }
105 145 }
106 146 }
107 147
108 148 #[instrument(skip_all)]
109 149 async fn check_task_snoozes(
110 - app: &tauri::AppHandle,
150 + outbox: &mut Outbox<Notifier>,
111 151 state: &Arc<AppState>,
112 - notified: &mut NotifiedItems,
152 + chosen: &Chosen,
113 153 ) -> Result<(), String> {
114 154 let now = Utc::now();
115 155
@@ -118,26 +158,41 @@
118 158 .list_snoozed(DESKTOP_USER_ID)
119 159 .map_err(|e| e.to_string())?;
120 160
161 + // A pass, not a series of one-shots: `CatchUp` is about a whole look at the
162 + // world, and this kind fires late anyway, so the distinction costs nothing
163 + // here and keeps every kind's shape the same.
164 + let mut pass = outbox.sweep(SNOOZE_EXPIRY);
165 +
121 166 for task in snoozed_tasks {
122 - // Check if snooze has expired and we haven't notified yet
123 - if let Some(snoozed_until) = task.snoozed_until
124 - && snoozed_until <= now
125 - && !notified.task_ids.contains(&task.id)
126 - {
127 - info!(task_id = %task.id, "Task snooze expired, sending notification");
128 -
129 - send_notification(
130 - app,
167 + let Some(snoozed_until) = task.snoozed_until else {
168 + continue;
169 + };
170 + if snoozed_until > now {
171 + continue;
172 + }
173 + // No "have we already told them" check here: that is exactly what the
174 + // outbox suppresses, and asking first would be keeping the set this
175 + // adoption deleted. A row that is unsnoozed below stops being listed
176 + // anyway, so the retry only happens when the unsnooze failed, which is
177 + // the case the shipped code's notified-set made unretryable.
178 + let sent = pass.offer(
179 + &Occurrence::new(
180 + SNOOZE_EXPIRY,
181 + task.id.to_string(),
131 182 "Task Resurfaced",
132 - &truncate_text(&task.description, 50).clone(),
133 - );
183 + truncate_text(&task.description, 50),
184 + ),
185 + chosen,
186 + );
187 + if sent.delivered() {
188 + info!(task_id = %task.id, "Task snooze expired, notification sent");
189 + }
134 190
135 - // Mark as notified
136 - notified.task_ids.insert(task.id);
137 -
138 - if let Err(e) = state.tasks.unsnooze(task.id, DESKTOP_USER_ID) {
139 - warn!(task_id = %task.id, error = %e, "Failed to unsnooze task after notification");
140 - }
191 + // Outside the branch above on purpose. The snooze ran out whether or
192 + // not the reader wanted to hear about it, and leaving the row snoozed
193 + // because a notification was suppressed would hide the task forever.
194 + if let Err(e) = state.tasks.unsnooze(task.id, DESKTOP_USER_ID) {
195 + warn!(task_id = %task.id, error = %e, "Failed to unsnooze task after notification");
141 196 }
142 197 }
143 198
@@ -145,9 +200,9 @@
145 200 }
146 201
147 202 fn check_email_snoozes(
148 - app: &tauri::AppHandle,
203 + outbox: &mut Outbox<Notifier>,
149 204 state: &Arc<AppState>,
150 - notified: &mut NotifiedItems,
205 + chosen: &Chosen,
151 206 ) -> Result<(), String> {
152 207 let now = Utc::now();
153 208
@@ -156,28 +211,32 @@
156 211 .list_snoozed(DESKTOP_USER_ID)
157 212 .map_err(|e| e.to_string())?;
158 213
214 + let mut pass = outbox.sweep(SNOOZE_EXPIRY);
215 +
159 216 for email in snoozed_emails {
160 - // Check if snooze has expired and we haven't notified yet
161 - if let Some(snoozed_until) = email.snoozed_until
162 - && snoozed_until <= now
163 - && !notified.email_ids.contains(&email.id)
164 - {
165 - send_notification(
166 - app,
217 + let Some(snoozed_until) = email.snoozed_until else {
218 + continue;
219 + };
220 + if snoozed_until > now {
221 + continue;
222 + }
223 + pass.offer(
224 + &Occurrence::new(
225 + SNOOZE_EXPIRY,
226 + email.id.to_string(),
167 227 "Email Resurfaced",
168 - &format!(
228 + format!(
169 229 "From: {} - {}",
170 230 truncate_text(&email.from, 20),
171 231 truncate_text(&email.subject, 40)
172 232 ),
173 - );
233 + ),
234 + chosen,
235 + );
174 236
175 - // Mark as notified
176 - notified.email_ids.insert(email.id);
177 -
178 - if let Err(e) = state.emails.unsnooze(email.id, DESKTOP_USER_ID) {
179 - warn!(email_id = %email.id, error = %e, "Failed to unsnooze email after notification");
180 - }
237 + // As above: the snooze is over either way.
238 + if let Err(e) = state.emails.unsnooze(email.id, DESKTOP_USER_ID) {
239 + warn!(email_id = %email.id, error = %e, "Failed to unsnooze email after notification");
181 240 }
182 241 }
183 242
@@ -185,56 +244,58 @@
185 244 }
186 245
187 246 fn check_overdue_responses(
188 - app: &tauri::AppHandle,
247 + outbox: &mut Outbox<Notifier>,
189 248 state: &Arc<AppState>,
190 - notified: &mut NotifiedItems,
249 + chosen: &Chosen,
191 250 ) -> Result<(), String> {
192 251 let now = Utc::now();
193 252
194 - // Check tasks waiting for response that are overdue
195 253 let waiting_tasks = state
196 254 .tasks
197 255 .list_waiting(DESKTOP_USER_ID)
198 256 .map_err(|e| e.to_string())?;
199 -
200 - for task in waiting_tasks {
201 - if let Some(expected_date) = task.expected_response_date {
202 - // Notify if response is overdue and we haven't notified yet
203 - // Use a unique key combining task ID and expected date to allow re-notification
204 - // if the expected date changes
205 - if expected_date < now && !notified.task_ids.contains(&task.id) {
206 - send_notification(
207 - app,
208 - "Response Overdue",
209 - &format!("Still waiting: {}", truncate_text(&task.description, 50)),
210 - );
211 - notified.task_ids.insert(task.id);
212 - }
213 - }
214 - }
215 -
216 - // Check emails waiting for response that are overdue
217 257 let waiting_emails = state
218 258 .emails
219 259 .list_waiting(DESKTOP_USER_ID)
220 260 .map_err(|e| e.to_string())?;
221 261
222 - for email in waiting_emails {
223 - if let Some(expected_date) = email.expected_response_date
224 - && expected_date < now
225 - && !notified.email_ids.contains(&email.id)
226 - {
227 - send_notification(
228 - app,
262 + let mut pass = outbox.sweep(RESPONSE_OVERDUE);
263 +
264 + // The tokens are prefixed because one kind covers both sides and a task and
265 + // an email can carry the same uuid -- the shipped code kept two sets for
266 + // exactly this and the prefix is what replaces them.
267 + for task in waiting_tasks {
268 + if task.expected_response_date.is_none_or(|due| due >= now) {
269 + continue;
270 + }
271 + pass.offer(
272 + &Occurrence::new(
273 + RESPONSE_OVERDUE,
274 + format!("task:{}", task.id),
229 275 "Response Overdue",
230 - &format!(
276 + format!("Still waiting: {}", truncate_text(&task.description, 50)),
277 + ),
278 + chosen,
279 + );
280 + }
281 +
282 + for email in waiting_emails {
283 + if email.expected_response_date.is_none_or(|due| due >= now) {
284 + continue;
285 + }
286 + pass.offer(
287 + &Occurrence::new(
288 + RESPONSE_OVERDUE,
289 + format!("email:{}", email.id),
290 + "Response Overdue",
291 + format!(
231 292 "No reply from: {} - {}",
232 293 truncate_text(&email.from, 20),
233 294 truncate_text(&email.subject, 30)
234 295 ),
235 - );
236 - notified.email_ids.insert(email.id);
237 - }
296 + ),
297 + chosen,
298 + );
238 299 }
239 300
240 301 Ok(())
@@ -248,9 +309,9 @@
248 309
249 310 #[instrument(skip_all)]
250 311 async fn check_event_reminders(
251 - app: &tauri::AppHandle,
312 + outbox: &mut Outbox<Notifier>,
252 313 state: &Arc<AppState>,
253 - notified: &mut NotifiedItems,
314 + chosen: &Chosen,
254 315 ) -> Result<(), String> {
255 316 let now = Utc::now();
256 317
@@ -259,6 +320,13 @@
259 320 .get_upcoming(DESKTOP_USER_ID, REMINDER_LOOKAHEAD_DAYS)
260 321 .map_err(|e| e.to_string())?;
261 322
323 + // The pass is what makes the FIRST look at the world the quiet one for this
324 + // kind, rather than its first occurrence. `Kind::quiet_after_restart` is
325 + // the declaration; the sweep is where it is honoured, and the
326 + // `reminders_bootstrapped` flag it replaces is gone along with the rule
327 + // that clearing the memory had to reset it.
328 + let mut pass = outbox.sweep(EVENT_REMINDER);
329 +
262 330 for event in events {
263 331 if event.reminder_offsets_seconds.is_empty() {
264 332 continue;
@@ -269,43 +337,37 @@
269 337 }
270 338
271 339 for offset_seconds in &event.reminder_offsets_seconds {
272 - let key = (event.id, *offset_seconds);
273 - if notified.event_reminders.contains(&key) {
274 - continue;
275 - }
276 -
277 340 let offset = chrono::Duration::seconds(*offset_seconds);
278 - let fire_time = event.start_time - offset;
279 - if fire_time > now {
280 - // Not yet time
341 + if event.start_time - offset > now {
342 + // Not yet time.
281 343 continue;
282 344 }
283 345 if event.start_time <= now {
284 - // Event has already started, don't surface a "5 minutes before"
285 - // reminder for something that's already running.
286 - notified.event_reminders.insert(key);
346 + // Already running: a "5 minutes before" reminder for it is
347 + // about a moment that has passed. Not offered rather than
348 + // offered-and-noted, so the outbox spends no memory on
349 + // occurrences it would never deliver; the query stops
350 + // returning the event once it is past.
287 351 continue;
288 352 }
289 353
290 - // On the first tick after launch, mark eligible reminders as fired
291 - // without notifying. This avoids spamming old reminders if the app
292 - // was closed past several fire times.
293 - if !notified.reminders_bootstrapped {
294 - notified.event_reminders.insert(key);
295 - continue;
296 - }
297 -
298 - info!(event_id = %event.id, offset_seconds = *offset_seconds, "Firing event reminder");
299 - send_notification(
300 - app,
301 - &reminder_title(*offset_seconds),
302 - &truncate_text(&event.title, 80),
354 + // One event has several reminders and each fires once, so the
355 + // offset is part of what makes this occurrence this one.
356 + let sent = pass.offer(
357 + &Occurrence::new(
358 + EVENT_REMINDER,
359 + format!("{}:{offset_seconds}", event.id),
360 + reminder_title(*offset_seconds),
361 + truncate_text(&event.title, 80),
362 + ),
363 + chosen,
303 364 );
304 - notified.event_reminders.insert(key);
365 + if sent.delivered() {
366 + info!(event_id = %event.id, offset_seconds = *offset_seconds, "Fired event reminder");
367 + }
305 368 }
306 369 }
307 370
308 - notified.reminders_bootstrapped = true;
309 371 Ok(())
310 372 }
Lines truncated
@@ -77,9 +77,13 @@
77 77
78 78 use std::collections::HashMap;
79 79
80 + use quasi_notifs::pane;
81 + use quasi_router::layout::Heading;
80 82 use quasi_router::screen::{Choice, Field, Row};
81 83 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
82 84
85 + use crate::notifs::NOTIFS;
86 +
83 87 use crate::commands::{all_config, write_config};
84 88 use crate::state::AppState;
85 89
@@ -231,7 +235,40 @@
231 235 }
232 236
233 237 /// Notifications.
238 + ///
239 + /// Two halves, and the point of the section is that they are two.
240 + ///
241 + /// The first is generated: [`quasi_notifs::pane`] emits a control per declared
242 + /// kind straight from [`NOTIFS`], so adding a kind adds its settings and there
243 + /// is no list here to keep in step. That replaced a hand-built section, which
244 + /// is what task `07830eb5` was for.
245 + ///
246 + /// The second is `event_lead_minutes`, which stays hand-written because it is
247 + /// not a notification setting at all: it colours a dot on the Events tab. It
248 + /// sat alone under this heading before the generated half arrived, and the risk
249 + /// the adoption had to avoid was folding it into the event-reminder kind by
250 + /// name-similarity. Generated keys are dotted and this one is not, so they
251 + /// cannot collide in the store; what they could still do is read alike in the
252 + /// pane, which is what the hint and the sub-heading below are for.
234 253 fn notifications(config: &HashMap<String, String>) -> Vec<Node> {
254 + let mut nodes = vec![Node::section("Notifications")];
255 +
256 + // Every generated control writes to one route under its own key. The
257 + // section heading the generator emits per category is why this does not add
258 + // one: "Reminders" is the category GoingsOn declared.
259 + let stored = |key: &str| config.get(key).cloned();
260 + nodes.push(Node::Region(pane::pane(
261 + &NOTIFS,
262 + &stored,
263 + &Action::post("/settings/notifications"),
264 + )));
265 +
266 + nodes.extend(indicator(config));
267 + nodes
268 + }
269 +
270 + /// The Events tab's indicator lead time. Not a notification; see [`notifications`].
271 + fn indicator(config: &HashMap<String, String>) -> Vec<Node> {
235 272 let options = [5, 10, 15, 30, 60]
236 273 .into_iter()
237 274 .map(|minutes| {
@@ -253,7 +290,10 @@
253 290 .collect();
254 291
255 292 vec![
256 - Node::section("Notifications"),
293 + Node::Heading {
294 + level: Heading::Section,
295 + text: "Events tab".to_owned(),
296 + },
257 297 setting(
258 298 choice_field(
259 299 config,
@@ -261,7 +301,7 @@
261 301 "Event indicator lead time",
262 302 options,
263 303 )
264 - .hint("How far in advance the Events tab dot turns yellow."),
304 + .hint("How far in advance the Events tab dot turns yellow. This is the indicator, not a notification."),
265 305 ),
266 306 ]
267 307 }
@@ -435,6 +475,49 @@
435 475 Ok(screen(state, section_for_key(&key))?.into())
436 476 }
437 477
478 + /// Write whatever the generated notifications pane sent.
479 + ///
480 + /// One route for the whole pane rather than one per key, because that is the
481 + /// shape [`quasi_notifs::pane`] emits: every generated control carries the same
482 + /// write action and sends its value under its own generated key, so the handler
483 + /// reads a name it never had to be told. The hand-written half of the section
484 + /// keeps [`set`], since its key is in the path.
485 + ///
486 + /// A checkbox that is off sends nothing at all -- that is how HTML submits one,
487 + /// and the description follows it -- so a key the payload does not carry is a
488 + /// key the reader just turned off. Iterating the registry rather than the
489 + /// payload is what makes that readable: every declared toggle is written on
490 + /// every post, as `true` or as `false`, so a stored value is never the absence
491 + /// of one.
492 + fn set_notifications(
493 + state: &AppState,
494 + request: quasi_router::Request,
495 + ) -> Result<Response, RouteError> {
496 + for kind in NOTIFS.kinds() {
497 + let key = quasi_notifs::config::enabled_key(kind.id);
498 + let on = request
499 + .payload
500 + .get(key.as_str())
501 + .is_some_and(|v| v == "true");
502 + write_config(state, &key, &on.to_string())
503 + .map_err(|error| RouteError::internal(error.to_string()))?;
504 +
505 + // A knob is a value rather than a presence, so an absent one was not
506 + // sent and is left alone. No kind declares one today; this is here so
507 + // that adding one to the registry needs no change to this route, which
508 + // is the whole promise of the generated pane.
509 + for knob in kind.options {
510 + let key = quasi_notifs::config::knob_key(kind.id, knob.id);
511 + if let Some(value) = request.payload.get(key.as_str()) {
512 + write_config(state, &key, value)
513 + .map_err(|error| RouteError::internal(error.to_string()))?;
514 + }
515 + }
516 + }
517 +
518 + Ok(screen(state, "notifications")?.into())
519 + }
520 +
438 521 /// The settings screen's routes.
439 522 #[must_use]
440 523 pub fn routes(router: Router<AppState>) -> Router<AppState> {
@@ -442,4 +525,5 @@
442 525 .get("/settings", index)
443 526 .get("/settings/{section}", section)
444 527 .post("/settings/config/{key}", set)
528 + .post("/settings/notifications", set_notifications)
445 529 }
@@ -129,6 +129,95 @@
129 129 }
130 130 }
131 131
132 + #[tokio::test]
133 + async fn the_notifications_section_is_generated_from_the_registry() {
134 + // Adoption's proof: the controls come from `crate::notifs::NOTIFS` rather
135 + // than from a list in the screen, so a kind added there appears here with
136 + // nothing edited in `settings.rs`.
137 + let state = state().await;
138 + let page = html(get(&state, "/settings/notifications"));
139 +
140 + for kind in crate::notifs::NOTIFS.kinds() {
141 + assert!(page.contains(kind.title), "{} is missing", kind.id);
142 + assert!(
143 + page.contains(&quasi_notifs::config::enabled_key(kind.id)),
144 + "{} does not carry its generated key",
145 + kind.id
146 + );
147 + }
148 + // The whole pane posts to one route, and every control names its own key.
149 + assert!(page.contains("/settings/notifications"));
150 + }
151 +
152 + #[tokio::test]
153 + async fn a_kind_that_ships_on_is_drawn_on_before_anyone_has_chosen() {
154 + // The migration fact: adopting the framework must not silently stop a
155 + // notification that fires today. A checkbox is on by presence.
156 + let state = state().await;
157 + let page = html(get(&state, "/settings/notifications"));
158 +
159 + let checkbox = page
160 + .split("<input")
161 + .find(|chunk| chunk.contains("snooze-expiry.enabled"))
162 + .expect("the snooze-expiry toggle is drawn");
163 + assert!(checkbox.contains("checked"), "{checkbox}");
164 + }
165 +
166 + #[tokio::test]
167 + async fn turning_a_kind_off_stores_false_and_leaves_the_others_alone() {
168 + let state = state().await;
169 +
170 + // A checkbox that is off sends nothing, which is how HTML submits one. So
171 + // a post carrying only the other two turns this one off.
172 + let page = html(post(
173 + &state,
174 + "/settings/notifications",
175 + Params::new()
176 + .with("response-overdue.enabled", "true")
177 + .with("event-reminder.enabled", "true"),
178 + ));
179 +
180 + assert_eq!(
181 + stored(&state, "snooze-expiry.enabled").as_deref(),
182 + Some("false")
183 + );
184 + assert_eq!(
185 + stored(&state, "response-overdue.enabled").as_deref(),
186 + Some("true")
187 + );
188 + assert_eq!(
189 + stored(&state, "event-reminder.enabled").as_deref(),
190 + Some("true")
191 + );
192 + // Answered with the section, re-read, like every other write on this screen.
193 + assert!(page.contains("Snoozed items resurface"));
194 +
195 + // And the pane now draws it off.
196 + let page = html(get(&state, "/settings/notifications"));
197 + let checkbox = page
198 + .split("<input")
199 + .find(|chunk| chunk.contains("snooze-expiry.enabled"))
200 + .expect("the toggle is still drawn");
201 + assert!(!checkbox.contains("checked"), "{checkbox}");
202 + }
203 +
204 + #[tokio::test]
205 + async fn the_events_tab_lead_time_is_not_one_of_the_notification_kinds() {
206 + // The trap `07830eb5` named: `event_lead_minutes` colours a dot on a tab
207 + // and is not a delivery setting. It keeps its own key, its own route, and
208 + // its own heading; the generated keys are dotted and it is not.
209 + let state = state().await;
210 + let page = html(get(&state, "/settings/notifications"));
211 +
212 + assert!(page.contains("/settings/config/event_lead_minutes"));
213 + assert!(!page.contains("event-reminder.lead"));
214 + assert!(page.contains("Events tab"));
215 + assert!(
216 + page.contains("This is the indicator, not a notification."),
217 + "the pane owes the distinction in words"
218 + );
219 + }
220 +
132 221 #[tokio::test]
133 222 async fn a_key_the_spec_does_not_declare_is_refused_by_the_same_check_the_command_uses() {
134 223 let state = state().await;
@@ -1,0 +1,138 @@
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. Adopted
5 + //! 2026-08-18 (task `07830eb5`); GoingsOn is the framework's first consumer.
6 + //!
7 + //! # Why this module is not inside `notifications`
8 + //!
9 + //! That module is desktop-only — `#[cfg(not(any(target_os = "ios", target_os =
10 + //! "android")))]` — because delivery is. The declaration is not: the generated
11 + //! keys are part of [`crate::config_key::CONFIG`] on every platform, or a phone
12 + //! would sync a table whose closed set it disagreed with, and refuse the rows
13 + //! the desktop wrote.
14 + //!
15 + //! # The three, and why they carry no knobs
16 + //!
17 + //! Each fires today and each declares [`shipping_on`](Kind::shipping_on), so
18 + //! adoption does not silently stop a notification somebody depends on. New
19 + //! kinds ship off, per Max 2026-08-17, and onboarding points at them
20 + //! (`b6c634fb`).
21 + //!
22 + //! No kind has a [`Knob`](quasi_notifs::Knob). That is measured rather than
23 + //! deferred: the one lead time in this app belongs to the *event*
24 + //! (`event.reminder_offsets_seconds`, several per event), which is the exact
25 + //! case `Setting`'s header says is not a knob, and the other lead time in the
26 + //! settings pane is `event_lead_minutes`, which is not about notifications at
27 + //! all. See [`crate::config_key`] for the collision that dotted keys settle.
28 + //!
29 + //! # What is not here, and it is a task rather than an omission
30 + //!
31 + //! **Task due-date reminders.** `07830eb5` said to add them here if they were
32 + //! three lines of declaration and to file them if they needed a scheduler.
33 + //! They need one: nothing in the watcher looks at `Task::due`, so the kind
34 + //! would arrive with a query, a lead time to compare against and a pass of its
35 + //! own. The declaration is the cheap half and shipping it alone would put a
36 + //! switch in the pane that turns nothing on.
37 + //!
38 + //! <!-- wiki: quasi-overview -->
39 +
40 + use quasi_notifs::{Kind, Registry};
41 +
42 + /// The snooze-expiry kind's id, and the token stem its occurrences use.
43 + pub const SNOOZE_EXPIRY: &str = "snooze-expiry";
44 + /// The overdue-response kind's id.
45 + pub const RESPONSE_OVERDUE: &str = "response-overdue";
46 + /// The event-reminder kind's id.
47 + pub const EVENT_REMINDER: &str = "event-reminder";
48 +
49 + /// Every kind GoingsOn declares.
50 + ///
51 + /// One category, because all three are the same promise: something you already
52 + /// asked to hear about, at the moment it comes due. A second category would be
53 + /// a second heading in the pane over one control, which reads as structure that
54 + /// is not there.
55 + static KINDS: &[Kind] = &[
56 + Kind::new(
57 + SNOOZE_EXPIRY,
58 + "Snoozed items resurface",
59 + "When a task or email you snoozed comes back.",
60 + "Reminders",
61 + )
62 + .shipping_on(),
63 + Kind::new(
64 + RESPONSE_OVERDUE,
65 + "Replies you are still waiting for",
66 + "When something you marked as waiting passes the date you expected it.",
67 + "Reminders",
68 + )
69 + .shipping_on(),
70 + // The one kind that is quiet after a restart, and the reason `CatchUp` is
71 + // per kind rather than per app: a snooze that ran out overnight is still
72 + // wanted, and a reminder for a meeting that started an hour ago is an
73 + // interruption about something already missed. The shipped watcher said
74 + // this with a `reminders_bootstrapped` flag it had to reset by hand
75 + // whenever it cleared its memory; the declaration says it once.
76 + Kind::new(
77 + EVENT_REMINDER,
78 + "Event reminders",
79 + "Before an event starts, at the lead times that event carries.",
80 + "Reminders",
81 + )
82 + .shipping_on()
83 + .quiet_after_restart(),
84 + ];
85 +
86 + /// GoingsOn's notification registry.
87 + pub static NOTIFS: Registry = Registry::new(KINDS);
88 +
89 + #[cfg(test)]
90 + mod tests {
91 + use super::*;
92 + use quasi_notifs::config::Unset;
93 +
94 + #[test]
95 + fn the_registry_is_well_formed() {
96 + // Everything a `const` cannot check: duplicate ids, key shape, and a
97 + // choice whose default is not one of its options.
98 + NOTIFS.check().expect("the registry is well formed");
99 + }
100 +
101 + #[test]
102 + fn every_shipped_kind_is_on_before_anyone_chooses() {
103 + // Adoption must not silently stop a notification that fires today.
104 + for kind in NOTIFS.kinds() {
105 + assert!(
106 + NOTIFS.is_on(kind.id, &Unset),
107 + "{} fires today and must ship on",
108 + kind.id
109 + );
110 + }
111 + }
112 +
113 + #[test]
114 + fn only_the_event_reminder_is_quiet_after_a_restart() {
115 + use quasi_notifs::CatchUp;
116 +
117 + let quiet: Vec<&str> = NOTIFS
118 + .kinds()
119 + .iter()
120 + .filter(|kind| kind.restart == CatchUp::Skip)
121 + .map(|kind| kind.id)
122 + .collect();
123 + assert_eq!(quiet, vec![EVENT_REMINDER]);
124 + }
125 +
126 + #[test]
127 + fn generated_keys_are_dotted() {
128 + // The whole of what keeps `event-reminder.lead` -- if one is ever
129 + // declared -- apart from `event_lead_minutes` in one table.
130 + for generated in NOTIFS.config() {
131 + assert!(
132 + generated.key.contains('.'),
133 + "{} is not dotted and could collide with an app key",
134 + generated.key
135 + );
136 + }
137 + }
138 + }