Skip to main content

Search

Search problems, hooks, and pages

Back to About

Layered Notification System

built with: Web Notifications + Web Audio + sonner

One framework-agnostic manager decides whether a notification fires at all, and whether it arrives as a desktop notification or an in-app toast.

A four-part split

The system is deliberately broken into four small modules. `registry.ts` holds per-category config. `prefs.ts` holds the user's mute settings. `policy.ts` decides whether a given notification is allowed to fire. `manager.ts` is the only module that touches the Web Notification API.

The point of the split is that new rules stay additive. Quiet hours, rate limits, or a focus-block mute all belong in `policy.ts` as one more condition — instead of an `if` statement scattered into whichever feature happens to fire a notification.

Categories carry an OS-level `tag`, which is the dedupe key the operating system uses. Distinct tags per category stop a timer alert from silently replacing a review reminder in the notification tray.

// src/notifications/policy.ts
export function isSuppressed(category: NotificationCategory, ctx: NotificationPolicyContext): boolean {
  // While a universal timer is active, don't nag about distraction.
  if (category === 'distraction' && ctx.timerActive) return true;
  return false;
}

export function isBlocked(category: NotificationCategory, ctx: NotificationPolicyContext): boolean {
  return isCategoryMuted(category) || isSuppressed(category, ctx);
}

Native or toast, resolved per category

Each category declares a default channel. `auto` means "use a real desktop notification if permission was granted, otherwise fall back to a sonner toast" — so the user still sees something even if they declined the permission prompt.

An explicit `native` request behaves differently on purpose: without permission it stays silent rather than degrading to a toast. That preserves the intent of alerts that are only meaningful when you are looking at another window.

// src/notifications/manager.ts
const channel = req.channel ?? cfg.defaultChannel;
const resolved = channel === 'auto' ? (canUseNative() && Notification.permission === 'granted' ? 'native' : 'snackbar') : channel;

let handle: NotificationHandle = NOOP_HANDLE;
if (resolved === 'native') {
  // Explicit 'native' request without permission stays silent rather than falling back.
  if (canUseNative() && Notification.permission === 'granted') handle = fireNative(req);
} else {
  fireSnackbar(req);
}

The chime ships no audio file

Alert sounds are synthesised at call time with the Web Audio API — a sine oscillator dropping from 880Hz to 660Hz with a short exponential envelope. No MP3 to download, cache, or version.

The whole thing is wrapped in a try/catch because browsers block audio construction until a user gesture has occurred; a blocked chime degrades to silence rather than an unhandled error.

// src/notifications/manager.ts
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, ctx.currentTime);
osc.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.2, ctx.currentTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.4);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.42);
osc.onended = () => ctx.close();

What currently fires it

Today the only caller is the study timer, which fires on pomodoro phase changes and countdown completion.

The `distraction` category — tab away and get nudged back — is still registered and still has its suppression rule, but nothing triggers it: that listener did not survive the rewrite. Wiring it back means a `visibilitychange` listener calling `notify({ category: "distraction", ... })`; the policy, prefs, and dedupe plumbing it needs are already in place.

Important

Gotcha

The manager never imports React. The provider injects a getter for the live policy context instead, which keeps the notification logic testable and callable from anywhere — including non-component code like the timer tick.

Where it lives in the repo

  • src/notifications/manager.ts
  • src/notifications/policy.ts
  • src/notifications/registry.ts
  • src/notifications/prefs.ts
  • src/notifications/notification-provider.tsx
  • src/components/timer/use-timer-tick.ts