Originally created by: adityaharishch
Stacked on [#288] (base = feat/daily-plan-today-tasks for migration lineage + plan.nudge's daily_plan dependency). Retarget to main once [#288] merges.
Replaces ad-hoc, hardcoded tray notification triggers with a centralised, redundant, industry-standard delivery system — and fixes the bug that made the existing toasts silently never appear.
The tray initialised the notification plugin and held the capability, but never requested OS authorization — so .show() was a silent no-op on macOS. The tray now requests permission on startup; the health/pause toasts that previously did nothing now work.
daemon producers ─enqueue()→ notifications outbox ─┬─drain→ tray relay ─→ macOS toast
(plan/worklog/fault) (dedup_key, channels, └─SSE──→ dashboard banner
scheduled/expiry, (NotificationBanner)
per-channel state)
prefs + quiet-hours filter applied ONCE at the delivery layer (lib/notifications.ts)
src/notifications.rs::enqueue() (idempotent on dedup_key), mirrors notices::raise. New events = one call, no new plumbing./api/notifications/pending, delivers, acks /delivered (at-least-once; re-fires until acked). No per-feature dedup.system_notices banner and gain a native toast (no double banner).notifications + src/notifications.rs emit module.last_notified_drafts./api/notifications/{pending,[id]/delivered,[id]/dismiss,stream}, banner store + NotificationBanner, prefs in lib/settings.ts.plan.nudge (morning, once/day, working-hours-gated), worklog.ready (scheduler), system.fault (notices bridge) + Settings → Notifications (master + per-type toggles + quiet hours).Master switch · per-type toggles (plan / worklog / faults) · quiet-hours window (wraps past midnight; gates toasts, not passive banners). Daemon tolerates the new settings fields (#[serde(default)]).
Daemon + tray cargo build/clippy -D warnings clean; UI npm run build clean; integration tests (etl, worklog_provider, task_linker_smoke — all run migrations incl. 042) pass.
🤖 Generated with Claude Code
Originally posted by: Akarsh-Hegde
🔍 Code review — centralised notification outbox
High-effort recall pass (4 finder angles, every survivor verified against source). Well-architected PR — the outbox/dedup design is clean and the consumer-side preference/quiet-hours filtering is in the right place. No crash- or data-loss-class bugs. Findings are about error visibility, a React lifecycle leak, and one channel asymmetry.
Findings (ranked)
1. Worklog-ready notification enqueue error is silently swallowed —
src/pm_worklog/scheduler.rs:254The parallel plan-nudge path logs its failure (
src/main.rs:813→tracing::debug!(error = %e, …)), but here the result is discarded. On any enqueue failure (e.g. a pre-migration-042 DB) the worklog-ready toast silently never fires and nothing is logged — drafts show ready in the dashboard but the tray stays quiet with no diagnostic. Mirror the nudge site:if let Err(e) = enqueue(…).await { tracing::warn!(error = %e, "worklog-ready notification enqueue failed") }. (Also the project convention is.context()+ log on DB calls.)2. Banner reconnect timer leaks on unmount —
ui/components/NotificationBanner.tsx:31-36es.onerrordoessetTimeout(connect, 5_000), but the effect cleanup only closes the current EventSource — it never clears that pending timeout. If the component unmounts during the 5s backoff window, the timeout firesconnect()on an unmounted component: a newEventSourceis created after cleanup ran (so it's never closed → leak) andsetItemsruns on an unmounted component. Track the timeout id in a ref (or acancelledflag) and clear it in the cleanup.3. Native toasts silently drop
deep_link(and severity) —tray/src-tauri/src/poll.rs:46-50&:325PendingNotifdeserializes only{id, title, body}andnotify()sets only title+body. Producers set click-throughs (.link("/plan"),.link("/worklogs")) and the banner channel renders an "Open →" link — but the macOS toast has no click action, so clicking a native notification does nothing. Asymmetry between the two channels; fine if intended for v1, but worth a tracking note since producers are already populatingdeep_link.Minor / cleanup
drain_notificationsswallows JSON parse errors —poll.rs:275r.json().await.unwrap_or_default()returns[]silently on a shape change. Consistent with the tray's best-effort style, but adebug!on the parse error would make a future regression visible.ui/lib/notifications-banner-store.ts:57-60once created the 30ssetIntervallives forever even after the last subscriber leaves. Low impact (broadcast()early-returns whencontrollers().size === 0, and it mirrors notices-store), but it's a permanent no-op timer onglobalThis.<= 0) → no-opUPDATEreturns200. Harmless given idempotency, butid > 0validation would be tighter.Candidates checked and cleared (so they aren't re-raised)
idx_notifications_native_pendingcan't servedelivered_native_at IS NULL" — false; SQLite stores NULLs in indexes and can seekcol IS NULL.NOW_ISO()called twice → inconsistent timestamps within a query" — false; it's read once intonowand reused (.all(now, now)).pendingNative()returns[]while quiet, so the tray fires/acks nothing anddelivered_native_atstays NULL until the window ends.ctrlmay be used uninitialized" (stream/route.ts) — false;start()runs synchronously duringReadableStreamconstruction, soctrlis assigned beforecancel()can run (same pattern as/api/notices/stream).🤖 Assisted review via Claude Code.
Originally posted by: adityaharishch
Thanks @Akarsh-Hegde — high-signal review. Addressed in e34e80d.
1. Worklog-ready enqueue error swallowed — ✅ Fixed.
let _ =→if let Err(e) … { tracing::warn!(error = %e, "worklog-ready notification enqueue failed") }, mirroring the plan-nudge site (src/pm_worklog/scheduler.rs:253).2. Banner reconnect timer leaks on unmount — ✅ Fixed. The 5s reconnect
setTimeoutis now tracked in aretryRefand there's acancelledflag; cleanup clears the timer, closes the EventSource, andconnect()early-returns if cancelled — so no post-unmountEventSource/setItems(ui/components/NotificationBanner.tsx).3. Native toast drops
deep_link/severity — Confirmed and intentionally deferred for v1, as you suspected. Click-to-navigate on a macOS toast needs Tauri notification actions + a focus/navigate handler, which is more than a field add. Rather than half-wire it, I documented the asymmetry atnotify()(tray/src-tauri/src/poll.rs): the banner channel carries the link, the native toast is title+body. Happy to open a follow-up issue to wire native click-through if you'd like it tracked.Minor —
id <= 0validation — ✅ Fixed in bothdeliveredanddismissroutes (!Number.isInteger(nid) || nid <= 0).Minor — banner-store interval never torn down — ✅ Fixed.
unsubscribe()now clears the shared 30s interval when the last subscriber leaves;subscribe()re-arms it viaensureInterval().Minor —
drain_notificationsswallows JSON parse errors — Intentionally not changed. The tray has zero logging infrastructure (notracing/logdep, noeprintln!anywhere intray/src-tauri/src/) — it's a deliberately silent menubar app. Adding a logging facility for one best-effort parse would break that consistency for little gain, and as you noted it's consistent with the tray's best-effort style. Glad to revisit if we ever add structured logging to the tray.Also thanks for the four cleared-candidate notes — saved a re-investigation pass. Builds: daemon
cargo check, traycargo check, andnpm run buildall green; full pre-push suite passed.Ticket changed by: adityaharishch