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
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