Il contesto
Il pannello di controllo che uso per coordinare il team commerciale aveva già promemoria automatici e una chat interna. Il passo successivo era un calendario condiviso: ogni operatore deve poter fissare un appuntamento personale o di reparto, e tutti i colleghi dello stesso reparto devono ricevere un avviso — con suono — all'orario stabilito, anche se hanno il pannello aperto su un'altra pagina.
Il sistema di notifiche esistente usava già Firebase Realtime Database, FCM per le push in background
e AudioContext per i suoni in-app. Il calendario doveva integrarsi in questo stack
senza aggiungere dipendenze esterne.
Stack: Firebase Realtime Database (path calendario/), FCM v1 per le push, AudioContext Web API per il suono in-app, JavaScript vanilla. L'interfaccia riutilizza il sistema di modal "flip + tron glow" già presente nel pannello.
Costruire il calendario: griglia, modal e visibilità
Struttura dell'interfaccia
Il punto di accesso è un pulsante nella dashboard, al posto del vecchio pulsante "Report PDF". Cliccandolo si apre un modal con la griglia mensile — navigatore mese con frecce ‹ ›, legenda con dot colorati (verde per appuntamenti personali, blu per quelli di reparto) e, in alto a destra, il pulsante + Appuntamento che apre un secondo modal sovrapposto per inserire titolo, data, ora e tipo.
Cliccando su un giorno si apre un ulteriore modal dedicato con l'elenco degli appuntamenti di quel giorno, i pulsanti ✏ Modifica e 🗑 Elimina su ciascuno, e il tasto per aggiungerne uno nuovo. Se il giorno è vuoto, il modal si apre comunque con solo il tasto Aggiungi.
Visibilità per reparto
Ogni appuntamento salvato su Firebase include il campo reparto, valorizzato con
l'ID del reparto del creatore (Onboarding, Commerciale, ecc.).
Quando un operatore apre il calendario, _loadCalendario() legge tutta la struttura
calendario/ e filtra: mostra solo gli appuntamenti personali dell'utente corrente
e quelli del suo reparto. Gli appuntamenti degli altri reparti non compaiono.
function _isVisible(app, currentUser, userRep) { if (app.tipo === 'personale') return app.creatore === currentUser; if (app.tipo === 'reparto') return app.reparto === userRep; return false; }
Scheduling locale e suono
Quando il listener Firebase riceve un appuntamento, _scheduleAppuntamento()
calcola il ritardo in millisecondi e arma un setTimeout. Alla scadenza,
compare un overlay full-screen position:fixed;inset:0 con suono triple-chime
e un pulsante "✓ Visto". Se l'utente non interagisce, l'overlay resta visibile.
Tutti gli eventi vengono loggati su Firebase (calendario_aggiunto, calendario_eliminato, calendario_fired).
Anti-duplicato: un flag calPushSent scritto su Firebase al momento dell'invio evita che più client dello stesso reparto inviino la stessa push FCM più volte per lo stesso appuntamento.
Il problema delle notifiche: nessuno riceveva nulla
Dopo il primo deploy, i test mostravano il calendario funzionante. Ma nella sessione reale con il team, fissando un appuntamento di reparto, nessun altro operatore riceveva suono né avviso — solo aprendo manualmente il modal calendario trovavano l'appuntamento. Il creatore riceveva tutto, tutti gli altri niente.
I sospetti iniziali erano tre: mismatch nell'ID reparto, throttling del browser per i tab in background, AudioContext bloccato dalla policy autoplay. Tutti plausibili — ma nessuno era il problema vero.
La diagnosi: il listener non partiva mai
Il pannello ha due flussi di accesso distinti: il login manuale (email + password) e il restore della sessione — il flusso che scatta quando un utente ha spuntato "ricordami" e riapre la PWA senza reinserire le credenziali.
Nel login manuale, _loadCalendario() e _initFCM() venivano chiamati
correttamente. Nel _restoreLoginSession(), invece, quelle due chiamate non c'erano.
Il risultato: [Utente_1], [Utente_2], [Utente_3] e tutti gli altri — che usano la PWA installata con sessione
salvata — non avevano mai il listener Firebase del calendario attivo. _calAppCache
restava vuoto, nessun timer veniva mai armato, e il token FCM non veniva aggiornato.
Il calendario si popolava solo aprendo il modal perché openCalendarioModal()
chiama _loadCalendario() internamente. Esattamente il sintomo osservato.
if (saved) { state.currentUser = saved; state.currentUserEmail = savedEmail; var ov = document.getElementById('loginOverlay'); if (ov) ov.style.display = 'none'; _updateNavUserChip(); if (typeof _initReminders === 'function') setTimeout(_initReminders, 600); // ── FIX: avvia calendario e FCM anche nel restore sessione ────────── // Senza queste chiamate, chi rientra con "ricordami" non ha mai // il listener Firebase attivo né il token FCM aggiornato. if (typeof _loadCalendario === 'function') setTimeout(_loadCalendario, 800); if (typeof window._initFCM === 'function') setTimeout(function() { window._initFCM(saved); }, 1500); }
Attenzione ai doppi avvii: _loadCalendario() è idempotente — stacca il listener Firebase precedente prima di riagganciarlo — quindi chiamarla due volte non crea problemi. Prima di aggiungere chiamate al restore, verificare sempre che la funzione target gestisca questo caso.
Il suono affidabile: AudioContext condiviso
Il secondo problema era il suono al ritorno sul pannello dopo che il tab era stato in background.
I browser moderni sospendono l'AudioContext dei tab non visibili e, soprattutto,
bloccano la riproduzione audio da qualsiasi sorgente che non sia stata sbloccata da un gesto
esplicito dell'utente (click, touch, tasto).
La soluzione era mantenere un singolo AudioContext condiviso (window._pcAudioCtx)
che viene sbloccato al primo gesto utente e riattivato anche all'evento visibilitychange
quando il tab ritorna in primo piano — che i browser trattano come un contesto di riproduzione
legittimo nella maggior parte dei casi.
-
1
Crea il contesto al primo gesto
Un listener su
pointerdown,keydown,touchstarteclickcreawindow._pcAudioCtxe chiamaresume()se è in statosuspended. -
2
Riattiva al ritorno sul tab
Anche
visibilitychangechiamaresume(). Questo garantisce che il suono parta quando l'utente torna sul pannello dopo aver usato un'altra scheda. -
3
_playReminderSound()riusa il contesto condivisoInvece di creare un nuovo
AudioContexta ogni suono (che potrebbe essere già in stato sospeso), la funzione prendewindow._pcAudioCtxse disponibile. Il fallback usa-e-getta resta per i rari browser senza contesto condiviso.
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) {} } (function _initAudioUnlock() { ['pointerdown', 'keydown', 'touchstart', 'click'].forEach(function(ev) { document.addEventListener(ev, _pcUnlockAudio, { passive: true }); }); document.addEventListener('visibilitychange', function() { if (document.visibilityState === 'visible') _pcUnlockAudio(); }); })();
Nota sui tab in background: i browser bloccano l'audio dai tab non visibili — è una restrizione della piattaforma, non del codice. La strategia scelta è suono + avviso affidabile al ritorno sul pannello, combinato con notifiche push FCM che usano il suono di sistema del SO anche quando la PWA è chiusa.
Notifiche push con suono di sistema
Per il suono in background — quando la PWA non è in primo piano — le notifiche push FCM devono dichiarare esplicitamente il suono nelle sezioni specifiche per piattaforma del payload. Il motivo è che ogni piattaforma usa un canale diverso: web push per i browser, APNs per iOS, FCM Android per i telefoni Android nativi.
const msg = { message: { token, // Browser desktop e Android via PWA webpush: { headers: { Urgency: 'high' }, notification: { title, body, tag, silent: false, // esplicito: usa suono di sistema requireInteraction: requireInteraction } }, // Android nativo android: { priority: 'HIGH', notification: { sound: 'default', default_sound: true } }, // iOS (senza aps.sound la push arriva MUTA) apns: { headers: { 'apns-priority': '10' }, payload: { aps: { sound: 'default', 'content-available': 1 } } }, data: strData } };
Per gli appuntamenti di reparto viene aggiunto requireInteraction: true,
così la notifica rimane visibile finché l'operatore non la tocca esplicitamente —
a differenza dei messaggi chat che scompaiono da soli dopo qualche secondo.
Takeaway
- Due flussi di accesso, due set di inizializzazioni. In una PWA con "ricordami", il login manuale e il restore della sessione sono percorsi separati nel codice. Qualsiasi servizio avviato al login manuale va avviato anche nel restore — altrimenti metà degli utenti lavora con funzionalità silenziosamente disattivate.
- AudioContext condiviso e sbloccato al primo gesto. Creare un
AudioContextnuovo a ogni riproduzione significa trovarlo già in statosuspendedquando il tab è tornato da background. Un singolo contesto condiviso, sbloccato alpointerdowne alvisibilitychange, risolve la maggior parte dei casi pratici. - Il suono in background richiede il suono di sistema.
AudioContextfunziona solo quando la pagina è aperta. Per notifiche con suono quando la PWA è in background o chiusa, serve FCM con i blocchiandroideapnsnel payload: senza il bloccoapns.payload.aps.sound, le push su iPhone arrivano sempre mute. - I bug di bootstrap non si trovano testando col proprio account. Lo sviluppatore fa quasi sempre login manuale — il restore della sessione lo usano tutti gli altri. Testare esplicitamente entrambi i flussi con account separati prima di fare deploy.
position:fixedper gli overlay su PWA multi-layer. Un overlay che deve coprire tutto lo schermo — anche in presenza di modal, chat e pagine interne — deve avere unz-indexsuperiore a qualsiasi layer preesistente, deve essere appeso aldocument.bodydirettamente (non dentro un container contransform) e deve usareinset:0.
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.
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.
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.
- 1Create the context on first gesture
A listener on
pointerdown,keydown,touchstart, andclickcreateswindow._pcAudioCtxand callsresume()if it's insuspendedstate. - 2Reactivate on tab return
visibilitychangealso callsresume(). This ensures sound fires when the user switches back to the panel. - 3
_playReminderSound()reuses the shared contextInstead of creating a new
AudioContextfor every sound (which might already be suspended), the function picks upwindow._pcAudioCtxif available.
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.
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 at manual login must also start at session restore — otherwise half your users are silently missing features.
- Shared AudioContext, unlocked on first gesture. Creating a new
AudioContexton every playback means finding it alreadysuspendedwhen returning from background. One shared context, unlocked onpointerdownandvisibilitychange, handles most real-world cases. - Background sound requires system sound.
AudioContextonly works while the page is open. For push notifications with sound when the PWA is in the background or closed, you need FCM withandroidandapnsblocks in the payload: withoutapns.payload.aps.sound, iPhone pushes always arrive silent. - 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.
position:fixedfor overlays in multi-layer PWAs. An overlay that must cover the entire screen needs a higherz-indexthan any existing layer, must be appended directly todocument.body(not inside a container withtransform), and should useinset:0.