max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+656 insertions,
-180 deletions
| @@ -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(¬e.title) | |
| 76 | + | .body(¬e.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 | + | } |