feat(web): notify 5 minutes before an upcoming event - #2871
Merged
Conversation
Compass had no way to reach a user who was not looking at the tab, so a meeting starting in five minutes went unannounced unless the calendar was on screen. Add browser notifications for upcoming timed events, opt-in and reversible from two surfaces. The opt-in is device-local and only ever written after the browser grants permission, so there is no "on but silent" state to explain: a grant revoked in site settings reads as off everywhere, and the palette offers to enable again. Permission is re-read on change and whenever the tab returns to the foreground. Scheduling rides the shared minute tick rather than a timer chain - 60s granularity is plenty for a five-minute lead, and it stays correct across sleep/wake where pending timers do not. Events that already started are never announced, so waking a laptop does not dump a burst of notifications for meetings long since begun, and each occurrence fires once, keyed on id and start time so a rescheduled event earns a fresh notification. - Palette: one toggle, labelled by the effective state, hidden where the browser has no Notification API. - Onboarding: a new showcase step between the create lesson and graduation. It is an offer, not a lesson - Enter allows, N passes, and both move on, so it adds no step a user can fail. The Notification API sits behind a port seam (mirroring toast.port.ts) so grant and deny are testable without a real browser prompt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsQ9jUGnM7J2hUk3Kqx98F
Findings from an independent diff review, in descending severity. The Life route sits outside the authenticated layout that mounts the notifier, so its palette was offering a toggle that persisted the opt-in, toasted "you'll get a heads-up", and then delivered nothing for as long as the tab stayed there - the exact "on but silent" state the feature is built to avoid. Drop the toggle from that palette. First run seeds a workday of sample events and now offers notifications in the same breath, so a new user could get an OS notification for a meeting that does not exist. Skip demo events; the filtering moves into toNotifiableEvents so the decision is covered without a React tree. Enter on the offer was intercepted at the document, in capture, whatever had focus - so "Not now" and "Skip to calendar" raised a permission prompt instead of doing what they say. Yield to a focused button (its native activation already does the right thing) and ignore auto-repeat, which otherwise carried the title-committing Enter into the offer. Both cases are regression-tested and fail without the guard. Also: sync the opt-in across tabs, so disabling in one stops the others rather than leaving them firing until reload; disable the Enable button while a prompt is outstanding instead of letting a no-op button look live. Tests: pin real timezones and assert literal times, rather than rebuilding the implementation's own formatting; make the unsupported-browser seam return "denied" like production does. Two accepted limitations, unchanged: an event in the first minutes after midnight gets a short lead, since the day query bounds the universe; and a notification whose delivery the browser silently drops still burns its de-dupe key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsQ9jUGnM7J2hUk3Kqx98F
#2879 rebuilt the showcase underneath this branch: two steps became six missions plus graduation, with a "Mission N of M" chip driven by a new SHOWCASE_MISSION_IDS list. The notifications offer now sits between the last mission and graduation, and stays out of the mission list. It asks for a browser permission rather than teaching a key, so numbering it "Mission 7 of 7" would have promised a lesson it does not deliver; getMissionLabel returns null for it and it renders no chip. Consequences of the new neighbours, all resolved toward main's structure: the practice side-letters D and U are gated to missions, since the offer has no board action to assist with, and C no longer opens a practice draft behind the offer. X still leaves from the offer, as it does everywhere. Main also dropped the shortcut_tip_* product events; its list is kept and the three notifications events append to it. Tests follow the same split: main's mission walk is unchanged - which is what proves the offer is not a mission - and gains the offer beat where it hands off to graduation. Both e2e specs press D through the six missions to reach the offer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsQ9jUGnM7J2hUk3Kqx98F
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Compass had no way to reach a user who was not looking at the tab, so a meeting starting in five minutes went unannounced unless the calendar was on screen. This adds browser notifications for upcoming timed events — opt-in, and reversible from two surfaces.
Scope is deliberately open-tab only: notifications fire while the Compass tab is open, including backgrounded or minimized. Reaching a closed tab needs a service worker, push subscriptions, VAPID keys and a delivery path in the backend; none of that is here, and no backend file is touched.
The opt-in. Device-local, and only ever written after the browser grants permission, so there is no "on but silent" state to explain. A grant revoked in site settings reads as off everywhere, and the palette goes back to offering to enable — clicking then re-asks the browser, which explains the block rather than silently doing nothing. Permission is re-read on change, on tab focus, and on
visibilitychange; the opt-in itself is re-read on thestorageevent so disabling in one tab stops every other tab.The scheduling. Rides the shared
useMinuteTickrather than a timer chain — 60s granularity is plenty for a five-minute lead, and it stays correct across sleep/wake where pending timers do not. Events that already started are never announced, so waking a laptop does not dump a burst of notifications for meetings long since begun. Each occurrence fires once, keyed on id and start time so a rescheduled event earns a fresh notification; the same key is the notificationtag, so a reload inside the lead window replaces rather than stacks.The two surfaces. The command palette gets one toggle, labelled by the effective state and hidden entirely where the browser has no Notification API. Onboarding gets an offer in the ShortcutShowcase, sitting between the last mission and graduation: Enter allows, N passes, and both move on, so it adds nothing a user can fail.
Key files:
packages/web/src/notifications/(new feature folder),components/ShortcutShowcase/showcase.steps.ts,ShortcutShowcase.tsx,components/CommandPalette/CommandPalette.tsx,views/Root.tsx.Simplicity
The feature folder mirrors the existing
timezone/layout, the store mirrorstheme.store.ts, and the toggle mirrorsuseThemeCmdItems.ts, so nothing here invents a new pattern.Reuse over new code:
useTodayTimedEventsis extracted fromuseUpNextEvent, not rewritten. That hook already had the awkward part — restoring real timed bounds for multi-day events whose all-day projection rewrites both dates — and duplicating it would have been the bug. Pure extraction; every consumer's derivation is byte-identical below the split.useMinuteTick,persistentBrowserStore,STORAGE_KEYS,showStatusToast, andsubscribeToStorageKeyare used as-is; no new timer, storage wrapper, or toast helper.stepId.Deliberately not built: no configurable lead time, no Settings-modal section (theme and timezone, the closest peers, are palette-only and gain nothing from a second surface), no service worker, no backend persistence.
The one new abstraction is
notification.port.ts, a seam over the Notification API mirroring the existingtoast.port.ts. It earns itself: jsdom has noNotification, so without it grant/deny paths could not be tested at all.Merged with the rebuilt showcase
#2879 landed mid-review and rebuilt the showcase underneath this branch: two steps became six missions plus graduation, with a "Mission N of M" chip driven by a new
SHOWCASE_MISSION_IDSlist. Merged in925618c, resolved toward main's structure.The offer now sits between the last mission and graduation and stays out of the mission list. It asks for a browser permission rather than teaching a key, so numbering it "Mission 7 of 7" would promise a lesson it does not deliver —
getMissionLabelreturnsnullfor it and it renders no chip.Two consequences of the new neighbours, both fixed here:
Main also dropped the
shortcut_tip_*product events; its list is kept, with the three notifications events appended.Tests follow the same split: main's mission walk is unchanged — which is what proves the offer is not a mission — and gains the offer beat where it hands off to graduation. Both e2e specs now press D through the six missions to reach the offer.
Automated validation
Onboarding e2e in a real Chromium, including a spec that grants notification permission, takes the offer with Enter, and asserts the opt-in is persisted:
Not automated, and worth a reviewer's own pass: an actual OS notification appearing ~5 minutes before a real event with the tab backgrounded, and the label flipping back to "Enable" after revoking permission in browser site settings. Both were reasoned about and unit-tested at the seam, but no CI here drives a real permission prompt or a real OS notification.
Independent review
A fresh diff-first reviewer went over the branch. It cleared the
useUpNextEventextraction (confirmed zero behavior change), the lead-window edges and de-dupe across the minute tick (no off-by-one, no missed or duplicate fire), fired-key memory growth (bounded to ~24h), and the step ordering, advance math and focus handling on the offer.Five findings were confirmed and fixed in
1fc3afa:/lifesits outside the authenticated layout that mounts the notifier, but its palette still offered the toggle — so enabling there persisted the opt-in, toasted "you'll get a heads-up", and delivered nothing. Toggle removed from that palette.Two review findings were judged and accepted rather than fixed, both pre-existing in shape: an event in the first minutes after local midnight gets a short lead (the day query bounds the event universe —
UpNextBannerhas the same horizon), and a notification the browser silently declines to deliver still burns its de-dupe key.The reviewer also flagged two tests as weak, and both are fixed: the "start time" assertion rebuilt the implementation's own
inEffectiveTimeZone(...).format(...)call and so would have passed against a timezone-blind implementation — it now pins Denver and Berlin and asserts literal times; and the unsupported-browser test seam returned a granted permission where production returns"denied".The two regression tests for finding 3 were verified to fail with the guard removed, rather than assumed load-bearing.
Test plan
New unit coverage, colocated:
upcoming-notifier.logic.test.ts(window edges, already-started events, rescheduled events, de-dupe, key pruning, demo/draft filtering, notification title and timezone-aware body),notification.store.test.ts(grant/deny/dismiss, revoked grant, unsupported browser, cross-tab sync, seeding),useNotificationCmdItems.test.ts(label by effective state, re-offer after revocation, hidden when unsupported), and twelve cases inShortcutShowcase.test.tsxcovering the offer — including that it renders no mission chip.One gap worth naming: the notifier hook's effect wiring — the
visibilitychange/focus/permission-change listeners — has no direct test. The decisions it delegates to (toNotifiableEvents,announceUpcomingEvents) are covered directly; testing the listeners themselves would have meant module-mocking a hook that other suites import, which trades a real flakiness risk for thin coverage.