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.

javascript — filtro visibilità
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.

javascript — fix in _restoreLoginSession
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, touchstart e click crea window._pcAudioCtx e chiama resume() se è in stato suspended.

  • 2
    Riattiva al ritorno sul tab

    Anche visibilitychange chiama resume(). Questo garantisce che il suono parta quando l'utente torna sul pannello dopo aver usato un'altra scheda.

  • 3
    _playReminderSound() riusa il contesto condiviso

    Invece di creare un nuovo AudioContext a ogni suono (che potrebbe essere già in stato sospeso), la funzione prende window._pcAudioCtx se disponibile. Il fallback usa-e-getta resta per i rari browser senza contesto condiviso.

javascript — sblocco AudioContext 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.

javascript — payload FCM v1 con suono su tutte le piattaforme
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

  1. 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.
  2. AudioContext condiviso e sbloccato al primo gesto. Creare un AudioContext nuovo a ogni riproduzione significa trovarlo già in stato suspended quando il tab è tornato da background. Un singolo contesto condiviso, sbloccato al pointerdown e al visibilitychange, risolve la maggior parte dei casi pratici.
  3. Il suono in background richiede il suono di sistema. AudioContext funziona solo quando la pagina è aperta. Per notifiche con suono quando la PWA è in background o chiusa, serve FCM con i blocchi android e apns nel payload: senza il blocco apns.payload.aps.sound, le push su iPhone arrivano sempre mute.
  4. 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.
  5. position:fixed per 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 un z-index superiore a qualsiasi layer preesistente, deve essere appeso al document.body direttamente (non dentro un container con transform) e deve usare inset:0.
← Torna al Blog