The context

The control panel for my sales team already had automatic reminders and an internal chat. The next step was a shared calendar: each operator should be able to set a personal or department appointment, and all colleagues in the same department should get an alert — with sound — at the scheduled time, even if the panel is open on a different page.

The existing notification system already used Firebase Realtime Database, FCM for background push, and AudioContext for in-app sounds. The calendar had to integrate into this stack without adding external dependencies.

ℹ️

Stack: Firebase Realtime Database (path calendario/), FCM v1 for push, Web Audio API AudioContext for in-app sound, vanilla JavaScript. The UI reuses the existing "flip + tron glow" modal system.

Building the calendar: grid, modal, and visibility

Interface structure

The entry point is a button in the dashboard. Clicking it opens a modal with the monthly grid — month navigator with ‹ › arrows, a legend with colored dots (green for personal, blue for department appointments), and a + Appointment button in the top-right that opens an overlay modal for title, date, time, and type.

Clicking a day opens a dedicated modal with the day's appointments, ✏ Edit and 🗑 Delete buttons on each one, and a button to add a new one. If the day is empty, the modal still opens with just the Add button.

Department visibility

Each appointment saved to Firebase includes a reparto (department) field, set to the creator's department ID. When an operator opens the calendar, _loadCalendario() reads the entire calendario/ structure and filters: only the current user's personal appointments and their department's appointments are shown.

javascript — visibility filter
function _isVisible(app, currentUser, userRep) {
  if (app.tipo === 'personale') return app.creatore === currentUser;
  if (app.tipo === 'reparto')   return app.reparto === userRep;
  return false;
}

Local scheduling and sound

When the Firebase listener receives an appointment, _scheduleAppuntamento() calculates the delay in milliseconds and arms a setTimeout. At trigger time, a full-screen overlay appears with a triple-chime sound and a "✓ Seen" button. If the user doesn't interact, the overlay stays visible.

💡

Anti-duplicate: a calPushSent flag written to Firebase at send time prevents multiple clients in the same department from each sending the same FCM push for the same appointment.

The notification problem: nobody was getting anything

After the first deploy, tests showed the calendar working. But in real team use, when someone set a department appointment, no other operator received sound or alert — only opening the calendar modal manually showed the appointment. The creator got everything; everyone else got nothing.

Initial suspects: department ID mismatch, browser throttling for background tabs, AudioContext blocked by autoplay policy. All plausible — none of them were the real problem.

The diagnosis: the listener never started

The panel has two distinct access flows: manual login (email + password) and session restore — the flow that fires when a user has checked "remember me" and reopens the PWA without re-entering credentials.

In manual login, _loadCalendario() and _initFCM() were called correctly. In _restoreLoginSession(), those two calls simply weren't there.

The result: [Utente_1], [Utente_2], [Utente_3], and everyone else — who use the installed PWA with a saved session — never had the Firebase calendar listener active. _calAppCache stayed empty, no timers were ever armed, and the FCM token was never refreshed. The calendar only populated when opening the modal because openCalendarioModal() calls _loadCalendario() internally. Exactly the symptom observed.

javascript — fix in _restoreLoginSession
if (saved) {
  state.currentUser = saved;
  // ... other setup ...

  // ── FIX: start calendar and FCM in session restore too ──────────
  // Without these calls, "remember me" users never have
  // an active Firebase listener or an updated FCM token.
  if (typeof _loadCalendario === 'function') setTimeout(_loadCalendario, 800);
  if (typeof window._initFCM === 'function') setTimeout(function() {
    window._initFCM(saved);
  }, 1500);
}
⚠️

Watch out for double starts: _loadCalendario() is idempotent — it detaches the previous Firebase listener before reattaching — so calling it twice is fine. Before adding calls to the restore flow, always verify the target function handles this case.

Reliable sound: a shared AudioContext

The second problem was sound when returning to the panel after the tab had been in the background. Modern browsers suspend the AudioContext of non-visible tabs and, more importantly, block audio playback from any source not unlocked by an explicit user gesture (click, touch, keypress).

The solution was to maintain a single shared AudioContext (window._pcAudioCtx) that gets unlocked on the first user gesture and also reactivated on the visibilitychange event when the tab comes back to the foreground — which browsers treat as a legitimate playback context in most cases.

  • 1
    Create the context on first gesture

    A listener on pointerdown, keydown, touchstart, and click creates window._pcAudioCtx and calls resume() if it's in suspended state.

  • 2
    Reactivate on tab return

    visibilitychange also calls resume(). This ensures sound fires when the user switches back to the panel.

  • 3
    _playReminderSound() reuses the shared context

    Instead of creating a new AudioContext for every sound (which might already be suspended), the function picks up window._pcAudioCtx if available.

javascript — shared AudioContext unlock
function _pcUnlockAudio() {
  try {
    if (!window._pcAudioCtx) {
      window._pcAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
    }
    if (window._pcAudioCtx.state === 'suspended') {
      window._pcAudioCtx.resume().catch(function(){});
    }
  } catch(e) {}
}

['pointerdown', 'keydown', 'touchstart', 'click'].forEach(function(ev) {
  document.addEventListener(ev, _pcUnlockAudio, { passive: true });
});
document.addEventListener('visibilitychange', function() {
  if (document.visibilityState === 'visible') _pcUnlockAudio();
});

Push notifications with system sound

For background sound — when the PWA isn't in the foreground — FCM push notifications must explicitly declare the sound in the platform-specific sections of the payload.

javascript — FCM v1 payload with sound on all platforms
const msg = {
  message: {
    token,
    webpush: {
      headers: { Urgency: 'high' },
      notification: { title, body, tag, silent: false, requireInteraction }
    },
    android: {
      priority: 'HIGH',
      notification: { sound: 'default', default_sound: true }
    },
    // iOS (without aps.sound the push arrives SILENT)
    apns: {
      headers: { 'apns-priority': '10' },
      payload: { aps: { sound: 'default', 'content-available': 1 } }
    },
    data: strData
  }
};

When a custom shared calendar on Firebase makes sense (and when it doesn't)

When to use it

  • Team already sharing other Firebase features (chat, notifications): a calendar native to the same ecosystem avoids integrating a second external service.
  • Reliable push notifications as a core requirement: a shared calendar event needs to reliably notify all members, not just be visible to whoever opens the app.
  • Custom notification behavior needed (persistence until explicitly dismissed, system sound): a custom calendar lets you control these details exactly, which a third-party calendar widget doesn't always allow.

When not to use it

  • External calendar integration is needed (Google Calendar, Outlook): a custom calendar requires building sync features from scratch that Google Calendar API already offers out of the box.
  • Advanced scheduling features (complex recurrence, RSVP invites, multiple time zones): these require significant development effort that an existing calendar handles natively.

Alternatives

For more standard calendar needs with external integration, Google Calendar API remains the more pragmatic choice over building everything from scratch, especially if the team already uses Google Workspace.

Common mistakes

  • Assuming all browsers handle audio the same way: autoplay policies differ across browsers, and a reliable notification sound needs a shared AudioContext initialized from an explicit user interaction.
  • Not testing push notifications across all target operating systems: behaviors like notification persistence until dismissed vary between iOS, Android and desktop, and need to be verified individually.

Takeaway

  1. Two access flows, two sets of initializations. In a PWA with "remember me", manual login and session restore are separate code paths. Any service started at manual login must also start at session restore — otherwise half your users are silently missing features.
  2. Shared AudioContext, unlocked on first gesture. Creating a new AudioContext on every playback means finding it already suspended when returning from background. One shared context, unlocked on pointerdown and visibilitychange, handles most real-world cases.
  3. Background sound requires system sound. AudioContext only works while the page is open. For push notifications with sound when the PWA is in the background or closed, you need FCM with android and apns blocks in the payload: without apns.payload.aps.sound, iPhone pushes always arrive silent.
  4. Bootstrap bugs don't show up when testing with your own account. Developers almost always do a manual login — session restore is what everyone else uses. Test both flows explicitly with separate accounts before deploying.
  5. position:fixed for overlays in multi-layer PWAs. An overlay that must cover the entire screen needs a higher z-index than any existing layer, must be appended directly to document.body (not inside a container with transform), and should use inset:0.

Frequently asked questions

Why did only people who logged in manually get calendar notifications, while those returning with "remember me" got nothing?

The calls that start the calendar's Firebase listener and initialize FCM were only present in the manual login flow, not in _restoreLoginSession() — the path used when someone reopens the PWA with a saved session. The fix was adding the same calls to the session restore flow too.

Why didn't the notification sound play when returning to the tab after it had been in the background?

Browsers suspend the AudioContext of non-visible tabs and block audio playback not unlocked by an explicit user gesture. The fix is a single shared AudioContext, unlocked on the first click/touch and reactivated on the visibilitychange event when the tab comes back into focus.

How do push notifications work when the PWA is completely closed?

FCM (Firebase Cloud Messaging) push notifications use the operating system's native notification sound and system, regardless of the PWA's state. They're complementary to the in-app sound: they ensure the user is alerted even if the app isn't open in any tab.

← Back to Blog