max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
12 files changed,
+379 insertions,
-8 deletions
| @@ -803,6 +803,7 @@ | |||
| 803 | 803 | <script src="js/keyboard.js"></script> | |
| 804 | 804 | ||
| 805 | 805 | <!-- UI --> | |
| 806 | + | <script src="js/notifs.js"></script> | |
| 806 | 807 | <script src="js/settings.js"></script> | |
| 807 | 808 | <script src="js/settings-sync.js"></script> | |
| 808 | 809 | <script src="js/settings-sharing.js"></script> |
| @@ -39,6 +39,10 @@ | |||
| 39 | 39 | /// dismissal) and `hint_dayplan` (the first-visit day-plan walkthrough) are | |
| 40 | 40 | /// per-device: the gestures those teach differ between a desktop and a tablet, | |
| 41 | 41 | /// so having seen them on one device says nothing about the next. | |
| 42 | + | /// `hint_notifications` (the pointer at the kinds that ship off) is the fourth | |
| 43 | + | /// of that family and is `Local` for a sharper reason than the other three: | |
| 44 | + | /// delivery is per device, so a phone that has never been told notifications | |
| 45 | + | /// exist has not been told, whatever the desktop was shown. | |
| 42 | 46 | const APP_KEYS: ConfigSpec = ConfigSpec::new( | |
| 43 | 47 | TABLE, | |
| 44 | 48 | &[ | |
| @@ -54,6 +58,7 @@ | |||
| 54 | 58 | ("welcomed", Posture::Local), | |
| 55 | 59 | ("hint_shortcuts", Posture::Local), | |
| 56 | 60 | ("hint_dayplan", Posture::Local), | |
| 61 | + | ("hint_notifications", Posture::Local), | |
| 57 | 62 | // The wam endpoint: the reachable address for a tailnet service differs | |
| 58 | 63 | // per machine, so syncing it would hand one device another's endpoint. | |
| 59 | 64 | // Its bearer token is a secret and lives in the OS keychain, not here. | |
| @@ -116,7 +121,13 @@ | |||
| 116 | 121 | "{key} is a preference and should sync" | |
| 117 | 122 | ); | |
| 118 | 123 | } | |
| 119 | - | for key in ["ui_mode", "welcomed", "hint_shortcuts", "hint_dayplan"] { | |
| 124 | + | for key in [ | |
| 125 | + | "ui_mode", | |
| 126 | + | "welcomed", | |
| 127 | + | "hint_shortcuts", | |
| 128 | + | "hint_dayplan", | |
| 129 | + | "hint_notifications", | |
| 130 | + | ] { | |
| 120 | 131 | assert!( | |
| 121 | 132 | !CONFIG.is_synced(key), | |
| 122 | 133 | "{key} is per-device and must not sync" |
| @@ -69,6 +69,8 @@ | |||
| 69 | 69 | $crate::commands::get_all_config, | |
| 70 | 70 | $crate::commands::set_config, | |
| 71 | 71 | $crate::commands::delete_config, | |
| 72 | + | // Notifications (the declared kinds, for the shipped settings screen) | |
| 73 | + | $crate::commands::list_notification_kinds, | |
| 72 | 74 | // Projects | |
| 73 | 75 | $crate::commands::list_projects, | |
| 74 | 76 | $crate::commands::get_project, |
| @@ -16,8 +16,11 @@ | |||
| 16 | 16 | //! | |
| 17 | 17 | //! Each fires today and each declares [`shipping_on`](Kind::shipping_on), so | |
| 18 | 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`). | |
| 19 | + | //! kinds ship off, per Max 2026-08-17, and onboarding points at them: the | |
| 20 | + | //! pointer counts the kinds that ship off and that nobody has answered yet | |
| 21 | + | //! (`b6c634fb`, in `app.js` over [`crate::commands::list_notification_kinds`]), | |
| 22 | + | //! so it stays quiet for as long as all three of these are the whole registry | |
| 23 | + | //! and fires for the first kind added without `shipping_on`. | |
| 21 | 24 | //! | |
| 22 | 25 | //! No kind has a [`Knob`](quasi_notifs::Knob). That is measured rather than | |
| 23 | 26 | //! deferred: the one lead time in this app belongs to the *event* |
| @@ -79,6 +79,8 @@ | |||
| 79 | 79 | } else if (!GoingsOn.config.get('go-hint-shortcuts')) { | |
| 80 | 80 | // One-time hint after first session | |
| 81 | 81 | setTimeout(() => showHint('go-hint-shortcuts', 'Press ? anytime to see keyboard shortcuts'), 2000); | |
| 82 | + | } else { | |
| 83 | + | pointAtNotifications(); | |
| 82 | 84 | } | |
| 83 | 85 | ||
| 84 | 86 | // After an OTA update, surface this version's changelog once. No-ops on a | |
| @@ -380,6 +382,43 @@ | |||
| 380 | 382 | GoingsOn.ui.openModal('Welcome to GoingsOn', content); | |
| 381 | 383 | } | |
| 382 | 384 | ||
| 385 | + | /** | |
| 386 | + | * Point at the notification kinds that ship off, once. | |
| 387 | + | * | |
| 388 | + | * Max, 2026-08-17: "We will have them off by default but onboarding will point | |
| 389 | + | * that out." Off-by-default without a pointer is a feature nobody finds, and | |
| 390 | + | * this is the pointer. | |
| 391 | + | * | |
| 392 | + | * It counts rather than enumerates. The registry knows the kinds, so a list in | |
| 393 | + | * this copy would go stale the first time one is added, and the count comes out | |
| 394 | + | * of the same declaration the settings pane renders. | |
| 395 | + | * | |
| 396 | + | * What it counts is kinds that ship off and that nobody has answered yet. A | |
| 397 | + | * kind the user switched off themselves is also off, and telling someone about | |
| 398 | + | * a choice they made is not a pointer. So it stays quiet when every kind is on | |
| 399 | + | * or already chosen, and does not burn its flag while waiting. That is the | |
| 400 | + | * state today: all three shipped kinds fired before the registry existed and | |
| 401 | + | * are grandfathered on. The first kind added without `shipping_on` is the one | |
| 402 | + | * this fires for. | |
| 403 | + | */ | |
| 404 | + | async function pointAtNotifications() { | |
| 405 | + | if (GoingsOn.config.get('go-hint-notifications')) return; | |
| 406 | + | if (!GoingsOn.notifs) return; | |
| 407 | + | const unanswered = (await GoingsOn.notifs.list()).filter(k => !k.shipsOn && !k.chosen); | |
| 408 | + | if (unanswered.length === 0) return; | |
| 409 | + | ||
| 410 | + | GoingsOn.config.set('go-hint-notifications', '1'); | |
| 411 | + | const subject = unanswered.length === 1 ? 'notification' : 'notifications'; | |
| 412 | + | GoingsOn.ui.showToast( | |
| 413 | + | `GoingsOn has ${unanswered.length} ${subject} it can send you. They are off until you turn them on.`, | |
| 414 | + | 'info', | |
| 415 | + | { | |
| 416 | + | duration: 12000, | |
| 417 | + | action: { label: 'Settings', fn: () => GoingsOn.settings.openAt('notifications') }, | |
| 418 | + | }, | |
| 419 | + | ); | |
| 420 | + | } | |
| 421 | + | ||
| 383 | 422 | /** | |
| 384 | 423 | * Show a one-time dismissible hint toast. Sets a config flag so it only shows once. | |
| 385 | 424 | */ | |
| @@ -395,6 +434,7 @@ | |||
| 395 | 434 | refreshCurrentViewData, | |
| 396 | 435 | showWelcome, | |
| 397 | 436 | showHint, | |
| 437 | + | pointAtNotifications, | |
| 398 | 438 | }; | |
| 399 | 439 | ||
| 400 | 440 | // Background/Foreground Transitions |
| @@ -29,6 +29,7 @@ | |||
| 29 | 29 | 'go-welcomed': 'welcomed', | |
| 30 | 30 | 'go-hint-shortcuts': 'hint_shortcuts', | |
| 31 | 31 | 'go-hint-dayplan': 'hint_dayplan', | |
| 32 | + | 'go-hint-notifications': 'hint_notifications', | |
| 32 | 33 | 'goingson-event-lead-minutes': 'event_lead_minutes', | |
| 33 | 34 | 'goingson-plan-nudges': 'plan_nudges', | |
| 34 | 35 | 'goingson-review-nudges': 'review_nudges', |
| @@ -31,6 +31,16 @@ | |||
| 31 | 31 | await showSection(currentSection); | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | + | /** | |
| 35 | + | * Open settings straight onto one section. What a pointer elsewhere in the | |
| 36 | + | * app uses to land somewhere specific, rather than on whichever section was | |
| 37 | + | * open last. | |
| 38 | + | */ | |
| 39 | + | async function openAt(section) { | |
| 40 | + | currentSection = section; | |
| 41 | + | await openSettings(); | |
| 42 | + | } | |
| 43 | + | ||
| 34 | 44 | /** | |
| 35 | 45 | * Called by navigation.loadViewData when settings view becomes active. | |
| 36 | 46 | * Renders the last-active section. Retained for back-compat with any | |
| @@ -78,7 +88,7 @@ | |||
| 78 | 88 | ||
| 79 | 89 | switch (section) { | |
| 80 | 90 | case 'appearance': await renderAppearance(container); break; | |
| 81 | - | case 'notifications': renderNotifications(container); break; | |
| 91 | + | case 'notifications': await renderNotifications(container); break; | |
| 82 | 92 | case 'email': await GoingsOn.emails.renderAccountsSection(container); break; | |
| 83 | 93 | case 'planning': renderPlanning(container); break; | |
| 84 | 94 | case 'sync': await renderSync(container); break; | |
| @@ -127,10 +137,55 @@ | |||
| 127 | 137 | `; | |
| 128 | 138 | } | |
| 129 | 139 | ||
| 130 | - | function renderNotifications(container) { | |
| 140 | + | /** | |
| 141 | + | * Two halves, and the point of the section is that they are two. | |
| 142 | + | * | |
| 143 | + | * The first is one switch per declared kind, rendered from the registry | |
| 144 | + | * (`GoingsOn.notifs`) rather than from a list here, so adding a kind adds | |
| 145 | + | * its switch. This mirrors what `quasi_notifs::pane` generates for the | |
| 146 | + | * described settings screen, down to the config keys it writes, so the flip | |
| 147 | + | * to that screen deletes this half rather than migrating it. | |
| 148 | + | * | |
| 149 | + | * The second is the event indicator lead time, which stays hand-written | |
| 150 | + | * because it is not a delivery setting at all: it colours a dot on the | |
| 151 | + | * Events tab. It sat alone under this heading before the kinds arrived, and | |
| 152 | + | * the sub-heading is what keeps the two from reading alike. | |
| 153 | + | */ | |
| 154 | + | async function renderNotifications(container) { | |
| 155 | + | const kinds = await GoingsOn.notifs.list(); | |
| 156 | + | // Grouped by the category each kind declares, in declaration order. | |
| 157 | + | // GoingsOn declares one today; reading it off the kinds rather than | |
| 158 | + | // assuming that is what keeps a second one from rendering under the | |
| 159 | + | // first one's heading. | |
| 160 | + | const categories = []; | |
| 161 | + | for (const k of kinds) { | |
| 162 | + | let group = categories.find(g => g.name === k.category); | |
| 163 | + | if (!group) categories.push((group = { name: k.category, kinds: [] })); | |
| 164 | + | group.kinds.push(k); | |
| 165 | + | } | |
| 166 | + | const declared = categories.map(group => ` | |
| 167 | + | <h4 class="settings-subheading">${esc(group.name)}</h4> | |
| 168 | + | ${group.kinds.map(k => ` | |
| 169 | + | <div class="settings-toggle-row"> | |
| 170 | + | <div> | |
| 171 | + | <p class="settings-subheading">${esc(k.title)}</p> | |
| 172 | + | <p class="settings-desc">${esc(k.summary)}</p> | |
| 173 | + | </div> | |
| 174 | + | <label class="toggle-switch"> | |
| 175 | + | <input type="checkbox" ${k.enabled ? 'checked' : ''} | |
| 176 | + | data-change="settings.onNotificationKindChange" | |
| 177 | + | data-a1="${escAttr(k.id)}" data-a2="@checked"> | |
| 178 | + | <span class="toggle-slider"></span> | |
| 179 | + | </label> | |
| 180 | + | </div> | |
| 181 | + | `).join('')} | |
| 182 | + | `).join(''); | |
| 183 | + | ||
| 131 | 184 | container.innerHTML = ` | |
| 132 | 185 | <div class="settings-section"> | |
| 133 | 186 | <h3 class="settings-heading">Notifications</h3> | |
| 187 | + | ${declared} | |
| 188 | + | <h4 class="settings-subheading">Events tab</h4> | |
| 134 | 189 | <div class="form-group"> | |
| 135 | 190 | <label class="form-label">Event indicator lead time</label> | |
| 136 | 191 | <select id="event-lead-time-selector" class="field" data-change="settings.onEventLeadTimeChange" data-a1="@value"> | |
| @@ -422,6 +477,11 @@ | |||
| 422 | 477 | ||
| 423 | 478 | // Helpers | |
| 424 | 479 | ||
| 480 | + | /** Flip one declared notification kind. The registry owns the key. */ | |
| 481 | + | function onNotificationKindChange(id, checked) { | |
| 482 | + | GoingsOn.notifs.setEnabled(id, checked === true || checked === 'true'); | |
| 483 | + | } | |
| 484 | + | ||
| 425 | 485 | function onEventLeadTimeChange(value) { | |
| 426 | 486 | GoingsOn.config.set('goingson-event-lead-minutes', value); | |
| 427 | 487 | if (GoingsOn.events && GoingsOn.events.updateEventStatusDot) { | |
| @@ -463,11 +523,13 @@ | |||
| 463 | 523 | ||
| 464 | 524 | GoingsOn.settings = { | |
| 465 | 525 | open: openSettings, | |
| 526 | + | openAt, | |
| 466 | 527 | load: loadSettings, | |
| 467 | 528 | showSection, | |
| 468 | 529 | goBack, | |
| 469 | 530 | openGettingStarted, | |
| 470 | 531 | openKeyboardShortcuts, | |
| 532 | + | onNotificationKindChange, | |
| 471 | 533 | onEventLeadTimeChange, | |
| 472 | 534 | onWorkHoursChange, | |
| 473 | 535 | setUpdateCheckOnLaunch, |
| @@ -35,6 +35,7 @@ | |||
| 35 | 35 | pub(crate) mod import_external; | |
| 36 | 36 | mod milestone; | |
| 37 | 37 | mod monthly_review; | |
| 38 | + | mod notifs; | |
| 38 | 39 | mod oauth; | |
| 39 | 40 | mod preferences; | |
| 40 | 41 | mod problem; | |
| @@ -135,6 +136,7 @@ | |||
| 135 | 136 | pub use import_external::*; | |
| 136 | 137 | pub use milestone::*; | |
| 137 | 138 | pub use monthly_review::*; | |
| 139 | + | pub use notifs::*; | |
| 138 | 140 | pub use oauth::*; | |
| 139 | 141 | pub use preferences::load as load_preferences; | |
| 140 | 142 | pub use preferences::*; |
| @@ -30,7 +30,8 @@ | |||
| 30 | 30 | //! | |
| 31 | 31 | //! # Which JS file belongs to which described module | |
| 32 | 32 | //! | |
| 33 | - | //! Measured 2026-08-15: 48 files under `frontend/js/` carry 327 `esc()` call | |
| 33 | + | //! Measured 2026-08-15, re-counted 2026-08-19: 48 files under `frontend/js/` | |
| 34 | + | //! carry 330 `esc()` call | |
| 34 | 35 | //! sites, plus 2 more in the `js/tests/run.js` gate. Every one of them is | |
| 35 | 36 | //! accounted for below, in one of four categories. | |
| 36 | 37 | //! | |
| @@ -41,7 +42,7 @@ | |||
| 41 | 42 | //! exist and both are counted. The count starts falling at the flip. Progress is | |
| 42 | 43 | //! the first list, not the number. | |
| 43 | 44 | //! | |
| 44 | - | //! ## Described, and retires at the flip (30 files, 225 sites) | |
| 45 | + | //! ## Described, and retires at the flip (30 files, 228 sites) | |
| 45 | 46 | //! | |
| 46 | 47 | //! | Module | JS counterpart | Sites | | |
| 47 | 48 | //! |---|---|---| | |
| @@ -53,7 +54,7 @@ | |||
| 53 | 54 | //! | [`day_planning`] | `day-planning-render.js` 8, `day-planning-schedule.js` 4, `day-planning-paint.js` 1 | 13 | | |
| 54 | 55 | //! | [`problems`] | `problems.js` | 10 | | |
| 55 | 56 | //! | [`monthly_review`] | `monthly-review.js` 3, `monthly-review-render.js` 5 | 8 | | |
| 56 | - | //! | [`settings`] | `settings.js` | 6 | | |
| 57 | + | //! | [`settings`] | `settings.js` | 9 | | |
| 57 | 58 | //! | [`board`] | `tasks-kanban.js` 3, `task-board.js` 1 | 4 | | |
| 58 | 59 | //! | [`task_list`] | `tasks.js` 2, `tasks-render.js` 8, `tasks-filter.js` 2, `task-forms.js` 1, `saved-views.js` 1 | 14 | | |
| 59 | 60 | //! | [`data`] | `import-external.js` 11, `import.js` 5, `export.js` 2 | 18 | | |
| @@ -61,6 +62,13 @@ | |||
| 61 | 62 | //! [`projects`] carries its dashboard as a submodule, which is the thirteenth | |
| 62 | 63 | //! described screen against twelve modules here. | |
| 63 | 64 | //! | |
| 65 | + | //! `settings.js` went 6 to 9 on 2026-08-19, and gained `notifs.js` beside it | |
| 66 | + | //! (no sites of its own). Both are the notification kinds reaching the shipped | |
| 67 | + | //! screen: [`settings`] generates that pane from the registry and the shipped | |
| 68 | + | //! screen could not, so it reads the same registry over one command. That is | |
| 69 | + | //! the rarer direction, a described screen's shape ported back, and it deletes | |
| 70 | + | //! at the flip like the rest of the row, along with `commands::notifs`. | |
| 71 | + | //! | |
| 64 | 72 | //! ## Stays JavaScript, by decision (4 files, 50 sites) | |
| 65 | 73 | //! | |
| 66 | 74 | //! - `settings-sync.js` 17, `settings-sharing.js` 14, `email-accounts.js` 10. |
| @@ -243,6 +243,12 @@ | |||
| 243 | 243 | /// is no list here to keep in step. That replaced a hand-built section, which | |
| 244 | 244 | /// is what task `07830eb5` was for. | |
| 245 | 245 | /// | |
| 246 | + | /// The shipped JavaScript screen renders the same half from the same registry, | |
| 247 | + | /// over [`crate::commands::list_notification_kinds`], because this screen is | |
| 248 | + | /// behind the `quasi` feature and a pane nobody can reach is not somewhere | |
| 249 | + | /// onboarding can point (`b6c634fb`). It writes the same generated keys, so the | |
| 250 | + | /// flip deletes it rather than migrating it. | |
| 251 | + | /// | |
| 246 | 252 | /// The second is `event_lead_minutes`, which stays hand-written because it is | |
| 247 | 253 | /// not a notification setting at all: it colours a dot on the Events tab. It | |
| 248 | 254 | /// sat alone under this heading before the generated half arrived, and the risk |
| @@ -1,0 +1,65 @@ | |||
| 1 | + | /** | |
| 2 | + | * GoingsOn - The declared notification kinds, read from the backend registry. | |
| 3 | + | * | |
| 4 | + | * There is no list of kinds in this file, and there must not be one. The | |
| 5 | + | * registry is `src-tauri/src/notifs.rs`; a kind's title, its one-line summary | |
| 6 | + | * and whether it ships on are declared there once, and both the settings pane | |
| 7 | + | * and the first-run pointer render what this returns. A hardcoded list here | |
| 8 | + | * would be a list that goes stale the first time a kind is added, which is the | |
| 9 | + | * failure the registry exists to make impossible. | |
| 10 | + | * | |
| 11 | + | * The read is cached for the session: the set of kinds is fixed at compile | |
| 12 | + | * time, and only the on/off moves. Writes update the cached entry rather than | |
| 13 | + | * re-fetching, so a toggle does not cost a round trip to learn what it just | |
| 14 | + | * did. | |
| 15 | + | * | |
| 16 | + | * This module retires when the described settings screen becomes the shipped | |
| 17 | + | * one; see `src-tauri/src/commands/notifs.rs`. | |
| 18 | + | */ | |
| 19 | + | (function () { | |
| 20 | + | 'use strict'; | |
| 21 | + | ||
| 22 | + | const invoke = window.__TAURI__.core.invoke; | |
| 23 | + | ||
| 24 | + | let cached = null; | |
| 25 | + | ||
| 26 | + | /** | |
| 27 | + | * Every declared kind: `{ id, title, summary, category, key, enabled, | |
| 28 | + | * shipsOn }`. Empty on failure, so a surface that cannot reach the registry | |
| 29 | + | * renders nothing rather than inventing a kind. | |
| 30 | + | */ | |
| 31 | + | async function list() { | |
| 32 | + | if (cached) return cached; | |
| 33 | + | try { | |
| 34 | + | cached = await invoke('list_notification_kinds'); | |
| 35 | + | } catch (e) { | |
| 36 | + | console.error('Failed to load notification kinds:', e); | |
| 37 | + | cached = []; | |
| 38 | + | } | |
| 39 | + | return cached; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /** | |
| 43 | + | * Turn one kind on or off, through the config key the registry generated. | |
| 44 | + | * | |
| 45 | + | * Written straight to the backend rather than through `GoingsOn.config`: | |
| 46 | + | * that cache knows a fixed set of hand-written keys and passes anything | |
| 47 | + | * else to localStorage, and a generated key is neither. Nothing reads these | |
| 48 | + | * synchronously, so the cache buys nothing here; the pane and the outbox | |
| 49 | + | * both resolve them through the registry. | |
| 50 | + | */ | |
| 51 | + | function setEnabled(id, on) { | |
| 52 | + | const kind = (cached || []).find((k) => k.id === id); | |
| 53 | + | if (!kind) return; | |
| 54 | + | kind.enabled = !!on; | |
| 55 | + | invoke('set_config', { key: kind.key, value: on ? 'true' : 'false' }) | |
| 56 | + | .catch((e) => console.error('set_config failed for', kind.key, e)); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /** The kinds that are off right now. What the pointer counts. */ | |
| 60 | + | async function off() { | |
| 61 | + | return (await list()).filter((k) => !k.enabled); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | GoingsOn.notifs = { list, setEnabled, off }; | |
| 65 | + | })(); |
| @@ -1,0 +1,170 @@ | |||
| 1 | + | //! What the frontend is allowed to know about [`crate::notifs::NOTIFS`]. | |
| 2 | + | //! | |
| 3 | + | //! The described settings screen reads the registry directly | |
| 4 | + | //! ([`quasi_notifs::pane`] in [`crate::quasi::settings`]), because it is Rust | |
| 5 | + | //! and the registry is a `static`. The shipped screens are JavaScript and | |
| 6 | + | //! cannot be, so this is the one place the declaration crosses the IPC | |
| 7 | + | //! boundary. | |
| 8 | + | //! | |
| 9 | + | //! It carries no copy of its own. Titles, summaries and categories are the | |
| 10 | + | //! [`Kind`](quasi_notifs::Kind)'s, and the on/off is resolved through | |
| 11 | + | //! [`Registry::is_on`](quasi_notifs::Registry::is_on) against the same | |
| 12 | + | //! `user_config` rows the pane reads, so the two surfaces cannot disagree about | |
| 13 | + | //! what is on. Writes go back through `set_config` under | |
| 14 | + | //! [`enabled_key`](quasi_notifs::config::enabled_key), which | |
| 15 | + | //! [`crate::config_key::CONFIG`] already declares. | |
| 16 | + | //! | |
| 17 | + | //! When the described settings screen becomes the shipped one, this module and | |
| 18 | + | //! its JavaScript callers delete together: the generated pane will be reading | |
| 19 | + | //! the registry in process. | |
| 20 | + | ||
| 21 | + | use std::collections::HashMap; | |
| 22 | + | use std::sync::Arc; | |
| 23 | + | ||
| 24 | + | use serde::Serialize; | |
| 25 | + | use tauri::State; | |
| 26 | + | use tracing::instrument; | |
| 27 | + | ||
| 28 | + | use super::ApiError; | |
| 29 | + | use super::config::all_config; | |
| 30 | + | use crate::notifs::NOTIFS; | |
| 31 | + | use crate::state::AppState; | |
| 32 | + | ||
| 33 | + | /// One declared notification kind, as the frontend sees it. | |
| 34 | + | #[derive(Debug, Clone, Serialize)] | |
| 35 | + | #[serde(rename_all = "camelCase")] | |
| 36 | + | pub struct NotificationKind { | |
| 37 | + | /// The kind's stable id, and the stem of its config key. | |
| 38 | + | pub id: String, | |
| 39 | + | /// What the settings pane calls it. | |
| 40 | + | pub title: String, | |
| 41 | + | /// The one line under the title. | |
| 42 | + | pub summary: String, | |
| 43 | + | /// The heading it groups under. | |
| 44 | + | pub category: String, | |
| 45 | + | /// The `user_config` key its on/off is stored under. | |
| 46 | + | pub key: String, | |
| 47 | + | /// Whether it is on right now, defaults resolved. | |
| 48 | + | pub enabled: bool, | |
| 49 | + | /// Whether it fires for someone who has never touched it. | |
| 50 | + | pub ships_on: bool, | |
| 51 | + | /// Whether anyone has ever set this kind's on/off. | |
| 52 | + | /// | |
| 53 | + | /// The field onboarding needs and [`enabled`](Self::enabled) cannot give | |
| 54 | + | /// it: a kind that is off because it ships off is one nobody has been told | |
| 55 | + | /// about, and a kind that is off because the user switched it off is one | |
| 56 | + | /// they have already answered. Both read `enabled: false`, and pointing at | |
| 57 | + | /// the second would be telling someone about a choice they made. | |
| 58 | + | pub chosen: bool, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Every declared kind, resolved against a set of stored config rows. | |
| 62 | + | /// | |
| 63 | + | /// Split from the command so it is testable: a `#[tauri::command]` takes | |
| 64 | + | /// `State` and a test has none, and what is worth testing here is the | |
| 65 | + | /// resolution rather than the IPC. | |
| 66 | + | fn kinds_from(config: &HashMap<String, String>) -> Vec<NotificationKind> { | |
| 67 | + | let stored = |key: &str| config.get(key).cloned(); | |
| 68 | + | ||
| 69 | + | NOTIFS | |
| 70 | + | .kinds() | |
| 71 | + | .iter() | |
| 72 | + | .map(|kind| { | |
| 73 | + | let key = quasi_notifs::config::enabled_key(kind.id); | |
| 74 | + | NotificationKind { | |
| 75 | + | id: kind.id.to_owned(), | |
| 76 | + | title: kind.title.to_owned(), | |
| 77 | + | summary: kind.summary.to_owned(), | |
| 78 | + | category: kind.category.to_owned(), | |
| 79 | + | enabled: NOTIFS.is_on(kind.id, &stored), | |
| 80 | + | ships_on: NOTIFS.is_on(kind.id, &quasi_notifs::config::Unset), | |
| 81 | + | chosen: config.contains_key(&key), | |
| 82 | + | key, | |
| 83 | + | } | |
| 84 | + | }) | |
| 85 | + | .collect() | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Every declared kind, with its current on/off resolved. | |
| 89 | + | #[tauri::command] | |
| 90 | + | #[instrument(skip_all)] | |
| 91 | + | pub async fn list_notification_kinds( | |
| 92 | + | state: State<'_, Arc<AppState>>, | |
| 93 | + | ) -> Result<Vec<NotificationKind>, ApiError> { | |
| 94 | + | Ok(kinds_from(&all_config(&state)?)) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | #[cfg(test)] | |
| 98 | + | mod tests { | |
| 99 | + | use super::*; | |
| 100 | + | ||
| 101 | + | /// The registry is the list. Nothing here filters or reorders it, so a kind | |
| 102 | + | /// cannot be declared and then not offered. | |
| 103 | + | #[test] | |
| 104 | + | fn every_declared_kind_is_offered() { | |
| 105 | + | let offered = kinds_from(&HashMap::new()); | |
| 106 | + | assert_eq!(offered.len(), NOTIFS.kinds().len()); | |
| 107 | + | for (offered, declared) in offered.iter().zip(NOTIFS.kinds()) { | |
| 108 | + | assert_eq!(offered.id, declared.id); | |
| 109 | + | assert_eq!(offered.title, declared.title); | |
| 110 | + | assert_eq!(offered.summary, declared.summary); | |
| 111 | + | assert_eq!(offered.category, declared.category); | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Nobody has chosen anything, so every kind reads as it ships. This is what | |
| 116 | + | /// onboarding sees on a fresh install. | |
| 117 | + | #[test] | |
| 118 | + | fn unset_reads_as_shipped() { | |
| 119 | + | for kind in kinds_from(&HashMap::new()) { | |
| 120 | + | assert!(!kind.chosen, "{} has been chosen by nobody", kind.id); | |
| 121 | + | assert_eq!( | |
| 122 | + | kind.enabled, kind.ships_on, | |
| 123 | + | "{} should read as it ships", | |
| 124 | + | kind.id | |
| 125 | + | ); | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Off-because-chosen and off-because-shipped are distinguishable, which is | |
| 130 | + | /// the whole reason `chosen` exists: onboarding points at the second and | |
| 131 | + | /// must stay quiet about the first. | |
| 132 | + | #[test] | |
| 133 | + | fn a_stored_choice_is_marked_chosen() { | |
| 134 | + | let declared = NOTIFS.kinds()[0]; | |
| 135 | + | let key = quasi_notifs::config::enabled_key(declared.id); | |
| 136 | + | let config = HashMap::from([(key.clone(), "false".to_owned())]); | |
| 137 | + | ||
| 138 | + | let offered = kinds_from(&config); | |
| 139 | + | let switched_off = offered | |
| 140 | + | .iter() | |
| 141 | + | .find(|k| k.id == declared.id) | |
| 142 | + | .expect("the kind is offered"); | |
| 143 | + | assert!(!switched_off.enabled, "the stored choice is honoured"); | |
| 144 | + | assert!(switched_off.chosen, "and it reads as a choice"); | |
| 145 | + | assert_eq!(switched_off.key, key, "under the generated key"); | |
| 146 | + | ||
| 147 | + | for other in offered.iter().filter(|k| k.id != declared.id) { | |
| 148 | + | assert!( | |
| 149 | + | !other.chosen, | |
| 150 | + | "{} was not written and is unchosen", | |
| 151 | + | other.id | |
| 152 | + | ); | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// The key the frontend writes back to is one `config_key::CONFIG` declares, | |
| 157 | + | /// or `set_config` would refuse the write and the switch would do nothing. | |
| 158 | + | #[test] | |
| 159 | + | fn every_offered_key_is_a_declared_config_key() { | |
| 160 | + | for kind in kinds_from(&HashMap::new()) { | |
| 161 | + | assert!( | |
| 162 | + | crate::config_key::CONFIG | |
| 163 | + | .keys() | |
| 164 | + | .any(|(known, _)| known == kind.key), | |
| 165 | + | "{} is offered but not a declared config key", | |
| 166 | + | kind.key | |
| 167 | + | ); | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | } |