The control panel for my sales team already had automatic reminders and an internal chat. The next step was a shared calendar: any operator sets a personal or department appointment, and every colleague in that department gets an alert — with sound — at the scheduled time, even if the panel is open on a different page.
The existing notification stack already used Firebase Realtime Database, FCM for background push, and AudioContext for in-app sound. The calendar had to slot into this without adding new dependencies.
Stack: Firebase Realtime Database (calendario/), FCM v1 for push, Web Audio API AudioContext for in-app sound, vanilla JavaScript.
Building the calendar: grid, modal, visibility
The entry point is a dashboard button opening a modal with a monthly grid — ‹ › month navigation, a legend with colored dots (green personal, blue department), and a + Appointment button.
Every appointment saved to Firebase carries a reparto (department) field set to the creator's department ID. When an operator opens the calendar, _loadCalendario() reads the full calendario/ tree and filters: only the user's personal appointments and their department's appointments are shown.
function _isVisible(app, currentUser, userRep) {
if (app.tipo === 'personale') return app.creatore === currentUser;
if (app.tipo === 'reparto') return app.reparto === userRep;
return false;
}
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 and a "✓ Seen" button.
A calPushSent flag written to Firebase at send time prevents multiple clients in the same department from each firing the same FCM push for the same appointment.
The notification problem: nobody was getting anything
After the first deploy, tests looked fine. But in real team use, when someone set a department appointment, no other operator received sound or alert — only manually opening the calendar modal showed it. 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 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.
Every operator using the installed PWA with a saved session never had the calendar's Firebase listener active. No timers were ever armed, and the FCM token never refreshed. The calendar only populated when opening the modal manually, because openCalendarioModal() calls _loadCalendario() internally — exactly the symptom observed.
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);
}
_loadCalendario() is idempotent — it detaches the previous listener before reattaching — so calling it twice is safe. Worth verifying before adding calls like this to a restore flow.
Reliable sound: a shared AudioContext
The second problem: sound wouldn't play when returning to the panel after the tab had been backgrounded. Browsers suspend the AudioContext of non-visible tabs and block audio playback not unlocked by an explicit user gesture.
The fix: maintain a single shared AudioContext (window._pcAudioCtx) unlocked on the first user gesture, and reactivated on visibilitychange when the tab returns to the foreground — a case browsers generally treat as legitimate.
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 sound when the PWA isn't in the foreground, FCM push notifications must explicitly declare sound in the platform-specific payload sections — otherwise iOS pushes arrive completely silent.
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
}
};
Takeaway
- 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 on login must also start on restore.
-
Shared AudioContext, unlocked on first gesture. A fresh
AudioContexton every playback risks being suspended when returning from background. One shared context, unlocked onpointerdownandvisibilitychange, covers most real cases. -
Background sound needs system sound.
AudioContextonly works while the page is open. For sound when the PWA is closed or backgrounded, FCM needsandroidandapnsblocks in the payload. - Bootstrap bugs hide from your own testing. Developers almost always log in manually — session restore is what everyone else uses. Test both flows explicitly, with separate accounts, before deploying.
-
position:fixedoverlays in multi-layer PWAs need a higherz-indexthan anything else, must be appended directly todocument.body(not inside atransform-ed container), and should useinset:0.
Full write-up on my blog: roversia.it/blog-03-calendario-firebase-pwa.html
Top comments (0)