Skip to main content

max / goingson

Task due-date reminders: the fourth kind, and the first with a knob The one notification a task manager is expected to have and this one did not. 07830eb5 split it out because it needs a pass of its own rather than three lines of declaration, which is still true of the pass and is no longer true of the query: the task said no repository method covered tasks due in a window, and list_due_between has been on the trait all along, serving the weekly review. The pass reads its lead time off the kind's knob each tick, so changing it in settings moves the window with nothing to invalidate, and it scans a week back as well as a lead forward. A deadline that passed while the app was shut is still a deadline, which is why this kind takes the default CatchUp::Fire where event reminders take quiet_after_restart: a meeting that started an hour ago is a moment that has passed, a due date is not. The token carries the due instant, so moving a deadline re-arms the reminder rather than reading as one already sent. It is the first kind to ship off, per Max 2026-08-17, which makes it the first thing the onboarding pointer has anything to point at. every_shipped_kind_is_on was asserting over the whole registry and now names the three the adoption grandfathered, since it was stating the opposite of the rule for anything new. It is also the first knob GoingsOn declares, so the shipped settings pane grew the knob half beside the toggles: a control per knob in the shape the declaration asks for, mirroring what quasi_notifs::pane emits. The described screen's write route needed no change, which was the promise it was written under.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-19 17:40 UTC
Signed with PGP, not checked
Commit: dda9a7caaa760cece9b7b8e89d69fdae9f9a7711
Parent: b154625
7 files changed, +440 insertions, -25 deletions
@@ -165,6 +165,8 @@
165 165 "snooze-expiry.enabled",
166 166 "response-overdue.enabled",
167 167 "event-reminder.enabled",
168 + "task-due.enabled",
169 + "task-due.lead",
168 170 ]
169 171 .into_iter()
170 172 .collect();
@@ -32,7 +32,9 @@
32 32 //! unconditional.
33 33
34 34 use crate::commands::all_config;
35 - use crate::notifs::{EVENT_REMINDER, NOTIFS, RESPONSE_OVERDUE, SNOOZE_EXPIRY};
35 + use crate::notifs::{
36 + EVENT_REMINDER, NOTIFS, RESPONSE_OVERDUE, SNOOZE_EXPIRY, TASK_DUE, TASK_DUE_LEAD,
37 + };
36 38 use crate::state::{AppState, DESKTOP_USER_ID};
37 39 use chrono::Utc;
38 40 use quasi_notifs::config::Settings;
@@ -142,6 +144,10 @@
142 144 if let Err(e) = check_event_reminders(&mut outbox, &state, &chosen).await {
143 145 error!(error = %e, "Error checking event reminders");
144 146 }
147 +
148 + if let Err(e) = check_tasks_due(&mut outbox, &state, &chosen) {
149 + error!(error = %e, "Error checking tasks coming due");
150 + }
145 151 }
146 152 }
147 153
@@ -371,6 +377,105 @@
371 377 Ok(())
372 378 }
373 379
380 + /// How far back the due-date scan reaches for a deadline it has not mentioned.
381 + ///
382 + /// The window's forward edge is the reader's lead time; this is its other end.
383 + /// A task that came due while the app was shut is still due, so the scan cannot
384 + /// start at `now`, and the outbox is what stops a week-old deadline being
385 + /// announced twice. Seven days rather than unbounded because the point is to
386 + /// catch the restart, not to read out the backlog: a deadline older than that
387 + /// is a thing the task list says, not a thing to interrupt someone about.
388 + const DUE_LOOKBACK_DAYS: i64 = 7;
389 +
390 + /// Tasks whose due date is inside the reader's lead time.
391 + ///
392 + /// The lead is a knob rather than a constant, so it is read per tick from the
393 + /// same config snapshot the on/off comes from: changing it in settings moves
394 + /// the window on the next pass with nothing to invalidate.
395 + ///
396 + /// One occurrence per task, and the token carries the due date. A deadline that
397 + /// is moved is a different deadline and re-arms, which is the behaviour a token
398 + /// of the bare task id would have lost.
399 + #[instrument(skip_all)]
400 + fn check_tasks_due(
401 + outbox: &mut Outbox<Notifier>,
402 + state: &Arc<AppState>,
403 + chosen: &Chosen,
404 + ) -> Result<(), String> {
405 + let now = Utc::now();
406 +
407 + // The declared default when nothing is stored, and the declaration is where
408 + // that number lives.
409 + let lead_seconds = NOTIFS
410 + .value(TASK_DUE, Some(TASK_DUE_LEAD), chosen)
411 + .and_then(|v| v.number())
412 + .unwrap_or_default();
413 + // A lead the reader typed backwards would otherwise make the window empty
414 + // and the kind silently dead.
415 + let lead = chrono::Duration::seconds(lead_seconds.max(0));
416 +
417 + let tasks = state
418 + .tasks
419 + .list_due_between(
420 + DESKTOP_USER_ID,
421 + now - chrono::Duration::days(DUE_LOOKBACK_DAYS),
422 + now + lead,
423 + )
424 + .map_err(|e| e.to_string())?;
425 +
426 + let mut pass = outbox.sweep(TASK_DUE);
427 +
428 + for task in tasks {
429 + let Some(due) = task.due else {
430 + continue;
431 + };
432 + // Snoozing a task is asking not to hear about it, and a deadline is not
433 + // the exception. The event pass reads the same way.
434 + if task.snoozed_until.is_some_and(|until| until > now) {
435 + continue;
436 + }
437 +
438 + let sent = pass.offer(
439 + &Occurrence::new(
440 + TASK_DUE,
441 + format!("{}:{}", task.id, due.timestamp()),
442 + due_title(due, now),
443 + truncate_text(&task.description, 80),
444 + ),
445 + chosen,
446 + );
447 + if sent.delivered() {
448 + info!(task_id = %task.id, %due, "Fired task due reminder");
449 + }
450 + }
451 +
452 + Ok(())
453 + }
454 +
455 + /// What a due-date notification is called, which depends on whether the
456 + /// deadline is ahead or behind.
457 + ///
458 + /// The same scan produces both: a task inside the lead time has not come due
459 + /// yet, and a task the reader was not told about before the app restarted has.
460 + /// Calling the second one "due soon" would be wrong in the one case the
461 + /// lookback window exists for.
462 + fn due_title(due: chrono::DateTime<Utc>, now: chrono::DateTime<Utc>) -> String {
463 + if due <= now {
464 + return "Task overdue".to_owned();
465 + }
466 + let minutes = (due - now).num_minutes();
467 + if minutes < 1 {
468 + return "Task due now".to_owned();
469 + }
470 + let hours = minutes / 60;
471 + if hours >= 1 && minutes % 60 == 0 {
472 + let label = if hours == 1 { "hour" } else { "hours" };
473 + return format!("Task due in {hours} {label}");
474 + }
475 + let label = if minutes == 1 { "minute" } else { "minutes" };
476 + format!("Task due in {minutes} {label}")
477 + }
478 +
374 479 /// Human-readable lead time for a reminder notification title.
375 480 fn reminder_title(offset_seconds: i64) -> String {
376 481 if offset_seconds <= 0 {
@@ -555,6 +660,110 @@
555 660 "only the kind that was turned off is silent"
556 661 );
557 662 }
663 +
664 + #[test]
665 + fn tasks_coming_due_say_nothing_until_the_reader_turns_them_on() {
666 + // The one kind that ships off. Nobody has chosen, so nothing is sent.
667 + let sent = Counting::default();
668 + let mut outbox = Outbox::new(NOTIFS, sent.clone());
669 +
670 + outbox.offer(&note(TASK_DUE, "task-1:1000"), &Unset);
671 + assert!(sent.0.borrow().is_empty(), "off until it is turned on");
672 +
673 + let on = Chosen(HashMap::from([(
674 + format!("{TASK_DUE}.enabled"),
675 + "true".to_owned(),
676 + )]));
677 + outbox.offer(&note(TASK_DUE, "task-1:1000"), &on);
678 + assert_eq!(sent.0.borrow().len(), 1, "and speaks once it is");
679 + }
680 +
681 + #[test]
682 + fn moving_a_due_date_re_arms_the_reminder() {
683 + // The token carries the deadline, so a rescheduled task is a new
684 + // occurrence rather than one already announced.
685 + let sent = Counting::default();
686 + let mut outbox = Outbox::new(NOTIFS, sent.clone());
687 + let on = Chosen(HashMap::from([(
688 + format!("{TASK_DUE}.enabled"),
689 + "true".to_owned(),
690 + )]));
691 +
692 + outbox.offer(&note(TASK_DUE, "task-1:1000"), &on);
693 + outbox.offer(&note(TASK_DUE, "task-1:1000"), &on);
694 + assert_eq!(sent.0.borrow().len(), 1, "the same deadline speaks once");
695 +
696 + outbox.offer(&note(TASK_DUE, "task-1:2000"), &on);
697 + assert_eq!(sent.0.borrow().len(), 2, "a moved deadline is a new one");
698 + }
699 +
700 + #[test]
701 + fn a_deadline_that_passed_while_the_app_was_shut_still_speaks() {
702 + // The contrast with `the_first_pass_of_event_reminders_is_quiet`: this
703 + // kind takes the default `CatchUp::Fire`, so its first pass delivers.
704 + let sent = Counting::default();
705 + let mut outbox = Outbox::new(NOTIFS, sent.clone());
706 + let on = Chosen(HashMap::from([(
707 + format!("{TASK_DUE}.enabled"),
708 + "true".to_owned(),
709 + )]));
710 +
711 + let mut first = outbox.sweep(TASK_DUE);
712 + first.offer(&note(TASK_DUE, "task-1:1000"), &on);
713 + drop(first);
714 +
715 + assert_eq!(sent.0.borrow().len(), 1, "a deadline is still a deadline");
716 + }
717 + }
718 +
719 + #[cfg(test)]
720 + mod due_title_tests {
721 + use super::due_title;
722 + use chrono::{TimeZone as _, Utc};
723 +
724 + fn at(seconds: i64) -> chrono::DateTime<Utc> {
725 + Utc.timestamp_opt(1_000_000 + seconds, 0).unwrap()
726 + }
727 +
728 + #[test]
729 + fn already_past() {
730 + assert_eq!(due_title(at(-60), at(0)), "Task overdue");
731 + }
732 +
733 + #[test]
734 + fn exactly_now() {
735 + assert_eq!(due_title(at(0), at(0)), "Task overdue");
736 + }
737 +
738 + #[test]
739 + fn within_the_minute() {
740 + assert_eq!(due_title(at(30), at(0)), "Task due now");
741 + }
742 +
743 + #[test]
744 + fn five_minutes() {
745 + assert_eq!(due_title(at(300), at(0)), "Task due in 5 minutes");
746 + }
747 +
748 + #[test]
749 + fn one_minute_is_singular() {
750 + assert_eq!(due_title(at(60), at(0)), "Task due in 1 minute");
751 + }
752 +
753 + #[test]
754 + fn the_declared_default_lead_reads_as_an_hour() {
755 + assert_eq!(due_title(at(3600), at(0)), "Task due in 1 hour");
756 + }
757 +
758 + #[test]
759 + fn two_hours() {
760 + assert_eq!(due_title(at(7200), at(0)), "Task due in 2 hours");
761 + }
762 +
763 + #[test]
764 + fn an_uneven_span_stays_in_minutes() {
765 + assert_eq!(due_title(at(5400), at(0)), "Task due in 90 minutes");
766 + }
558 767 }
559 768
560 769 #[cfg(test)]
@@ -29,18 +29,22 @@
29 29 //! settings pane is `event_lead_minutes`, which is not about notifications at
30 30 //! all. See [`crate::config_key`] for the collision that dotted keys settle.
31 31 //!
32 - //! # What is not here, and it is a task rather than an omission
32 + //! # The fourth, which needed a scheduler
33 33 //!
34 - //! **Task due-date reminders.** `07830eb5` said to add them here if they were
35 - //! three lines of declaration and to file them if they needed a scheduler.
36 - //! They need one: nothing in the watcher looks at `Task::due`, so the kind
37 - //! would arrive with a query, a lead time to compare against and a pass of its
38 - //! own. The declaration is the cheap half and shipping it alone would put a
39 - //! switch in the pane that turns nothing on.
34 + //! **Task due-date reminders**, added 2026-08-19 (`f74ad2f8`). `07830eb5` split
35 + //! it out because the declaration is the cheap half and shipping it alone would
36 + //! have put a switch in the pane that turns nothing on: it needed a pass of its
37 + //! own in the watcher and a lead time to compare against. It has both now.
38 + //!
39 + //! It is the first kind to ship off, which is what Max's 2026-08-17 direction
40 + //! says every new kind does, and therefore the first thing the onboarding
41 + //! pointer has to point at. And it carries the first [`Knob`](quasi_notifs::Knob)
42 + //! GoingsOn declares, so it is also the first exercise of the generated number
43 + //! control and of the knob half of the write route.
40 44 //!
41 45 //! <!-- wiki: quasi-overview -->
42 46
43 - use quasi_notifs::{Kind, Registry};
47 + use quasi_notifs::{Kind, Knob, Registry, Setting};
44 48
45 49 /// The snooze-expiry kind's id, and the token stem its occurrences use.
46 50 pub const SNOOZE_EXPIRY: &str = "snooze-expiry";
@@ -48,6 +52,26 @@
48 52 pub const RESPONSE_OVERDUE: &str = "response-overdue";
49 53 /// The event-reminder kind's id.
50 54 pub const EVENT_REMINDER: &str = "event-reminder";
55 + /// The task-due kind's id.
56 + pub const TASK_DUE: &str = "task-due";
57 + /// The task-due kind's lead-time knob.
58 + pub const TASK_DUE_LEAD: &str = "lead";
59 +
60 + /// How long before a task is due it says so, before anyone chooses.
61 + ///
62 + /// An hour. A task's `due` is a full instant rather than a date (the form
63 + /// parses "friday 3pm"), so a lead measured in days would fire at a time that
64 + /// has nothing to do with the deadline, and one measured in minutes is a
65 + /// reminder you cannot act on. An hour is long enough to do something and short
66 + /// enough to still be about today.
67 + const TASK_DUE_LEAD_DEFAULT_SECONDS: i64 = 60 * 60;
68 +
69 + /// The task-due kind's knobs. One: how far ahead it speaks up.
70 + static TASK_DUE_KNOBS: &[Knob] = &[Knob::new(
71 + TASK_DUE_LEAD,
72 + "Lead time (seconds)",
73 + Setting::Seconds(TASK_DUE_LEAD_DEFAULT_SECONDS),
74 + )];
51 75
52 76 /// Every kind GoingsOn declares.
53 77 ///
@@ -84,6 +108,19 @@
84 108 )
85 109 .shipping_on()
86 110 .quiet_after_restart(),
111 + // The one kind that ships off, because it is new and every new kind does.
112 + // It takes the default `CatchUp::Fire` rather than `quiet_after_restart`,
113 + // and the contrast with the kind above it is the whole of what `CatchUp`
114 + // is for: a meeting that started while the app was shut is a moment that
115 + // has passed, and a deadline that passed while the app was shut is still
116 + // a deadline.
117 + Kind::new(
118 + TASK_DUE,
119 + "Tasks coming due",
120 + "Before a task's due date, at the lead time you choose.",
121 + "Reminders",
122 + )
123 + .with(TASK_DUE_KNOBS),
87 124 ];
88 125
89 126 /// GoingsOn's notification registry.
@@ -102,17 +139,33 @@
102 139 }
103 140
104 141 #[test]
105 - fn every_shipped_kind_is_on_before_anyone_chooses() {
142 + fn every_kind_that_fired_before_the_registry_still_ships_on() {
106 143 // Adoption must not silently stop a notification that fires today.
107 - for kind in NOTIFS.kinds() {
144 + // Scoped to the three the adoption grandfathered rather than to the
145 + // whole registry, because a new kind ships off and this test used to
146 + // say the opposite.
147 + for id in [SNOOZE_EXPIRY, RESPONSE_OVERDUE, EVENT_REMINDER] {
108 148 assert!(
109 - NOTIFS.is_on(kind.id, &Unset),
110 - "{} fires today and must ship on",
111 - kind.id
149 + NOTIFS.is_on(id, &Unset),
150 + "{id} fired before the registry existed and must ship on"
112 151 );
113 152 }
114 153 }
115 154
155 + #[test]
156 + fn the_task_due_kind_ships_off() {
157 + // Max, 2026-08-17: new kinds ship off, and onboarding points at them.
158 + assert!(!NOTIFS.is_on(TASK_DUE, &Unset), "a new kind ships off");
159 + }
160 +
161 + #[test]
162 + fn the_lead_time_defaults_to_an_hour() {
163 + let lead = NOTIFS
164 + .value(TASK_DUE, Some(TASK_DUE_LEAD), &Unset)
165 + .expect("the knob is declared");
166 + assert_eq!(lead.number(), Some(TASK_DUE_LEAD_DEFAULT_SECONDS));
167 + }
168 +
116 169 #[test]
117 170 fn only_the_event_reminder_is_quiet_after_a_restart() {
118 171 use quasi_notifs::CatchUp;
@@ -25,7 +25,8 @@
25 25
26 26 /**
27 27 * Every declared kind: `{ id, title, summary, category, key, enabled,
28 - * shipsOn }`. Empty on failure, so a surface that cannot reach the registry
28 + * shipsOn, chosen, knobs }`, each knob `{ id, label, key, control, value,
29 + * options }`. Empty on failure, so a surface that cannot reach the registry
29 30 * renders nothing rather than inventing a kind.
30 31 */
31 32 async function list() {
@@ -56,10 +57,24 @@
56 57 .catch((e) => console.error('set_config failed for', kind.key, e));
57 58 }
58 59
60 + /**
61 + * Set one knob of one kind. The value is text, as the store holds it; the
62 + * registry reads it back under the type the declaration gave it, so a
63 + * number that does not parse is ignored rather than honoured as garbage.
64 + */
65 + function setKnob(kindId, knobId, value) {
66 + const kind = (cached || []).find((k) => k.id === kindId);
67 + const knob = kind && kind.knobs.find((n) => n.id === knobId);
68 + if (!knob) return;
69 + knob.value = String(value);
70 + invoke('set_config', { key: knob.key, value: knob.value })
71 + .catch((e) => console.error('set_config failed for', knob.key, e));
72 + }
73 +
59 74 /** The kinds that are off right now. What the pointer counts. */
60 75 async function off() {
61 76 return (await list()).filter((k) => !k.enabled);
62 77 }
63 78
64 - GoingsOn.notifs = { list, setEnabled, off };
79 + GoingsOn.notifs = { list, setEnabled, setKnob, off };
65 80 })();
@@ -178,6 +178,7 @@
178 178 <span class="toggle-slider"></span>
179 179 </label>
180 180 </div>
181 + ${k.knobs.map(n => notificationKnob(k, n)).join('')}
181 182 `).join('')}
182 183 `).join('');
183 184
@@ -202,6 +203,40 @@
202 203 `;
203 204 }
204 205
206 + /**
207 + * One knob of one kind, in the control its declaration asks for.
208 + *
209 + * Offered whether or not the kind is on, which is what the generated pane
210 + * does and for the same reason: what a kind would do is worth reading
211 + * before turning it on.
212 + */
213 + function notificationKnob(kind, knob) {
214 + const name = `${kind.id}-${knob.id}`;
215 + const change = `data-change="settings.onNotificationKnobChange"
216 + data-a1="${escAttr(kind.id)}" data-a2="${escAttr(knob.id)}"`;
217 + let control;
218 + if (knob.control === 'checkbox') {
219 + control = `<label class="toggle-switch">
220 + <input type="checkbox" id="${escAttr(name)}" ${knob.value === 'true' ? 'checked' : ''}
221 + ${change} data-a3="@checked">
222 + <span class="toggle-slider"></span>
223 + </label>`;
224 + } else if (knob.control === 'choice') {
225 + control = `<select class="field" id="${escAttr(name)}" ${change} data-a3="@value">
226 + ${knob.options.map(o => `<option value="${escAttr(o)}" ${knob.value === o ? 'selected' : ''}>${esc(o)}</option>`).join('')}
227 + </select>`;
228 + } else {
229 + control = `<input type="number" class="field" id="${escAttr(name)}" value="${escAttr(knob.value)}"
230 + ${change} data-a3="@value">`;
231 + }
232 + return `
233 + <div class="form-group">
234 + <label class="form-label" for="${escAttr(name)}">${esc(knob.label)}</label>
235 + ${control}
236 + </div>
237 + `;
238 + }
239 +
205 240 function renderPlanning(container) {
206 241 container.innerHTML = `
207 242 <div class="settings-section">
@@ -482,6 +517,11 @@
482 517 GoingsOn.notifs.setEnabled(id, checked === true || checked === 'true');
483 518 }
484 519
520 + /** Set one knob of one declared kind. The registry owns the key. */
521 + function onNotificationKnobChange(kindId, knobId, value) {
522 + GoingsOn.notifs.setKnob(kindId, knobId, value === true ? 'true' : value === false ? 'false' : value);
523 + }
524 +
485 525 function onEventLeadTimeChange(value) {
486 526 GoingsOn.config.set('goingson-event-lead-minutes', value);
487 527 if (GoingsOn.events && GoingsOn.events.updateEventStatusDot) {
@@ -530,6 +570,7 @@
530 570 openGettingStarted,
531 571 openKeyboardShortcuts,
532 572 onNotificationKindChange,
573 + onNotificationKnobChange,
533 574 onEventLeadTimeChange,
534 575 onWorkHoursChange,
535 576 setUpdateCheckOnLaunch,
@@ -29,6 +29,7 @@
29 29 use super::config::all_config;
30 30 use crate::notifs::NOTIFS;
31 31 use crate::state::AppState;
32 + use quasi_notifs::Setting;
32 33
33 34 /// One declared notification kind, as the frontend sees it.
34 35 #[derive(Debug, Clone, Serialize)]
@@ -48,6 +49,8 @@
48 49 pub enabled: bool,
49 50 /// Whether it fires for someone who has never touched it.
50 51 pub ships_on: bool,
52 + /// The kind's knobs, in the order it offers them.
53 + pub knobs: Vec<NotificationKnob>,
51 54 /// Whether anyone has ever set this kind's on/off.
52 55 ///
53 56 /// The field onboarding needs and [`enabled`](Self::enabled) cannot give
@@ -58,6 +61,29 @@
58 61 pub chosen: bool,
59 62 }
60 63
64 + /// One knob of one kind, with its current value.
65 + ///
66 + /// [`control`](Self::control) is the same mapping [`quasi_notifs::pane`] makes
67 + /// from a [`Setting`](quasi_notifs::Setting) to a field kind, so the two
68 + /// surfaces offer the same control for the same declaration. It is a string
69 + /// rather than an enum because its only reader is JavaScript.
70 + #[derive(Debug, Clone, Serialize)]
71 + #[serde(rename_all = "camelCase")]
72 + pub struct NotificationKnob {
73 + /// The knob's id, under its kind's.
74 + pub id: String,
75 + /// What the settings pane calls it.
76 + pub label: String,
77 + /// The `user_config` key it is stored under.
78 + pub key: String,
79 + /// `checkbox`, `number` or `choice`.
80 + pub control: String,
81 + /// Its current value as text, defaults resolved.
82 + pub value: String,
83 + /// What may be chosen, for `choice`. Empty otherwise.
84 + pub options: Vec<String>,
85 + }
86 +
61 87 /// Every declared kind, resolved against a set of stored config rows.
62 88 ///
63 89 /// Split from the command so it is testable: a `#[tauri::command]` takes
@@ -76,6 +102,30 @@
76 102 title: kind.title.to_owned(),
77 103 summary: kind.summary.to_owned(),
78 104 category: kind.category.to_owned(),
105 + knobs: kind
106 + .options
107 + .iter()
108 + .map(|knob| {
109 + let (control, options) = match knob.value {
110 + Setting::Toggle(_) => ("checkbox", Vec::new()),
111 + Setting::Seconds(_) | Setting::Count(_) => ("number", Vec::new()),
112 + Setting::Choice { options, .. } => {
113 + ("choice", options.iter().map(|o| (*o).to_owned()).collect())
114 + }
115 + };
116 + NotificationKnob {
117 + id: knob.id.to_owned(),
118 + label: knob.label.to_owned(),
119 + key: quasi_notifs::config::knob_key(kind.id, knob.id),
120 + control: control.to_owned(),
121 + value: NOTIFS
122 + .value(kind.id, Some(knob.id), &stored)
123 + .map(|v| v.text())
124 + .unwrap_or_default(),
125 + options,
126 + }
127 + })
128 + .collect(),
79 129 enabled: NOTIFS.is_on(kind.id, &stored),
80 130 ships_on: NOTIFS.is_on(kind.id, &quasi_notifs::config::Unset),
81 131 chosen: config.contains_key(&key),
@@ -153,18 +203,63 @@
153 203 }
154 204 }
155 205
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.
206 + /// The keys the frontend writes back to are ones `config_key::CONFIG`
207 + /// declares, or `set_config` would refuse the write and the control would
208 + /// do nothing.
158 209 #[test]
159 210 fn every_offered_key_is_a_declared_config_key() {
211 + let declared = |key: &str| {
212 + crate::config_key::CONFIG
213 + .keys()
214 + .any(|(known, _)| known == key)
215 + };
160 216 for kind in kinds_from(&HashMap::new()) {
161 217 assert!(
162 - crate::config_key::CONFIG
163 - .keys()
164 - .any(|(known, _)| known == kind.key),
218 + declared(&kind.key),
165 219 "{} is offered but not a declared config key",
166 220 kind.key
167 221 );
222 + for knob in &kind.knobs {
223 + assert!(
224 + declared(&knob.key),
225 + "{} is offered but not a declared config key",
226 + knob.key
227 + );
228 + }
168 229 }
169 230 }
231 +
232 + /// A knob arrives with the value the declaration gives it, and a stored one
233 + /// wins. What keeps the shipped pane from offering an empty number box for
234 + /// a knob that has a default.
235 + #[test]
236 + fn a_knob_carries_its_value() {
237 + let declared = NOTIFS
238 + .kinds()
239 + .iter()
240 + .find(|k| !k.options.is_empty())
241 + .expect("some kind declares a knob");
242 + let knob = &declared.options[0];
243 + let key = quasi_notifs::config::knob_key(declared.id, knob.id);
244 +
245 + let unset = kinds_from(&HashMap::new());
246 + let offered = unset
247 + .iter()
248 + .find(|k| k.id == declared.id)
249 + .expect("the kind is offered");
250 + assert_eq!(offered.knobs.len(), declared.options.len());
251 + assert_eq!(offered.knobs[0].key, key);
252 + assert!(
253 + !offered.knobs[0].value.is_empty(),
254 + "the declared default, not an empty box"
255 + );
256 +
257 + let config = HashMap::from([(key, "1800".to_owned())]);
258 + let stored = kinds_from(&config);
259 + let offered = stored
260 + .iter()
261 + .find(|k| k.id == declared.id)
262 + .expect("the kind is offered");
263 + assert_eq!(offered.knobs[0].value, "1800", "a stored value wins");
264 + }
170 265 }
@@ -31,7 +31,7 @@
31 31 //! # Which JS file belongs to which described module
32 32 //!
33 33 //! Measured 2026-08-15, re-counted 2026-08-19: 48 files under `frontend/js/`
34 - //! carry 330 `esc()` call
34 + //! carry 332 `esc()` call
35 35 //! sites, plus 2 more in the `js/tests/run.js` gate. Every one of them is
36 36 //! accounted for below, in one of four categories.
37 37 //!
@@ -42,7 +42,7 @@
42 42 //! exist and both are counted. The count starts falling at the flip. Progress is
43 43 //! the first list, not the number.
44 44 //!
45 - //! ## Described, and retires at the flip (30 files, 228 sites)
45 + //! ## Described, and retires at the flip (30 files, 230 sites)
46 46 //!
47 47 //! | Module | JS counterpart | Sites |
48 48 //! |---|---|---|
@@ -54,7 +54,7 @@
54 54 //! | [`day_planning`] | `day-planning-render.js` 8, `day-planning-schedule.js` 4, `day-planning-paint.js` 1 | 13 |
55 55 //! | [`problems`] | `problems.js` | 10 |
56 56 //! | [`monthly_review`] | `monthly-review.js` 3, `monthly-review-render.js` 5 | 8 |
57 - //! | [`settings`] | `settings.js` | 9 |
57 + //! | [`settings`] | `settings.js` | 11 |
58 58 //! | [`board`] | `tasks-kanban.js` 3, `task-board.js` 1 | 4 |
59 59 //! | [`task_list`] | `tasks.js` 2, `tasks-render.js` 8, `tasks-filter.js` 2, `task-forms.js` 1, `saved-views.js` 1 | 14 |
60 60 //! | [`data`] | `import-external.js` 11, `import.js` 5, `export.js` 2 | 18 |
@@ -62,7 +62,7 @@
62 62 //! [`projects`] carries its dashboard as a submodule, which is the thirteenth
63 63 //! described screen against twelve modules here.
64 64 //!
65 - //! `settings.js` went 6 to 9 on 2026-08-19, and gained `notifs.js` beside it
65 + //! `settings.js` went 6 to 11 on 2026-08-19, and gained `notifs.js` beside it
66 66 //! (no sites of its own). Both are the notification kinds reaching the shipped
67 67 //! screen: [`settings`] generates that pane from the registry and the shipped
68 68 //! screen could not, so it reads the same registry over one command. That is