Skip to content

feat(web): notify 5 minutes before an upcoming event - #2871

Merged
tyler-dane merged 3 commits into
mainfrom
claude/calendar-notifications-plan-ieqk20
Aug 26, 2026
Merged

feat(web): notify 5 minutes before an upcoming event#2871
tyler-dane merged 3 commits into
mainfrom
claude/calendar-notifications-plan-ieqk20

Conversation

@tyler-dane

@tyler-dane tyler-dane commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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 the storage event so disabling in one tab stops every other tab.

The scheduling. Rides the shared useMinuteTick 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. Each occurrence fires once, keyed on id and start time so a rescheduled event earns a fresh notification; the same key is the notification tag, 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 mirrors theme.store.ts, and the toggle mirrors useThemeCmdItems.ts, so nothing here invents a new pattern.

Reuse over new code:

  • useTodayTimedEvents is extracted from useUpNextEvent, 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.
  • Existing useMinuteTick, persistentBrowserStore, STORAGE_KEYS, showStatusToast, and subscribeToStorageKey are used as-is; no new timer, storage wrapper, or toast helper.
  • The showcase needed no change to its step type — the renderer already branches its buttons on 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 existing toast.port.ts. It earns itself: jsdom has no Notification, 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_IDS list. Merged in 925618c, 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 — getMissionLabel returns null for it and it renders no chip.

Two consequences of the new neighbours, both fixed here:

  • The practice side-letters D ("Do it for me") and U (skip to signup) are gated to missions — the offer has no board action to assist with. X still leaves, as everywhere.
  • C no longer opens a practice draft behind the offer. Every other non-graduation step teaches the board; this one does not, and a test caught the editor opening underneath it.

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:

bunx playwright test e2e/onboarding      6 passed

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 useUpNextEvent extraction (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:

  1. The Life palette lied. /life sits 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.
  2. Sample events would fire real notifications. First run seeds a workday of demo events and offers notifications in the same breath, so a new user could get an OS notification for a meeting that does not exist. Demo events are filtered out.
  3. Enter hijacked the focused button. The step intercepted Enter 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. Enter now yields to a focused button (native activation already does the right thing) and ignores auto-repeat, which otherwise carried a title-committing Enter into the offer.
  4. Cross-tab disable was ignored by other open tabs until reload.
  5. The Enable button looked live while an unanswered prompt was outstanding.

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 — UpNextBanner has 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

bun run test:web                    2394 pass, 0 fail
bun run lint                        0 errors (12 pre-existing warnings, untouched files)
bun run type-check                  clean
bun run knip                        clean
bunx playwright test e2e/onboarding 6 passed

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 in ShortcutShowcase.test.tsx covering 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.

claude added 3 commits August 25, 2026 21:14
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
@tyler-dane
tyler-dane merged commit 229fafe into main Aug 26, 2026
23 checks passed
@tyler-dane
tyler-dane deleted the claude/calendar-notifications-plan-ieqk20 branch August 26, 2026 10:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants