Il contesto

Il pannello di controllo che uso per coordinare il team commerciale aveva già un sistema di notifiche push (FCM), chat DM e di reparto, promemoria automatici con suono, richieste di ferie e permessi, e un calendario condiviso. Ognuno di questi sistemi funzionava bene — ma erano isole separate.

Se Andrea voleva sapere se la sua richiesta di ferie era stata approvata, doveva andare nella sezione ferie. Per i messaggi, aprire la chat. Per i promemoria scattati, guardare il log globale cercando tra le voci di tutti gli altri. Non c'era nessun punto di raccolta unificato.

L'idea era semplice: una campanella 🔔 da cui ogni operatore accede a uno storico personale di tutto ciò che lo riguarda — come un LOG ma filtrato sull'utente corrente, consultabile anche a distanza di settimane. Niente notifiche push aggiuntive, solo un archivio strutturato sempre disponibile.

ℹ️

Stack: Firebase Realtime Database (chat, calendario, promemoria), Firestore (activityLog per ferie e permessi), JavaScript vanilla. Il pannello è una PWA single-page senza framework.

Architettura: sei categorie, due database

La prima decisione è stata dove leggere i dati per ogni categoria. Il pannello usa già sia Firebase Realtime Database sia Firestore, e le due tecnologie hanno caratteristiche diverse che rendono ciascuna più adatta a certi tipi di dati.

Perché Firestore per ferie e permessi

Le richieste di ferie e permessi vivono già su Firestore nella collezione activityLog, dove ogni documento registra l'operatore, il tipo di evento, il timestamp e lo stato. Sono dati storici che non cambiano in tempo reale: una ferie approvata ieri non si modifica più. Usare .get() (lettura singola) invece di .onSnapshot() (real-time) permette di caricare lo storico una volta sola all'apertura del pannello, con un costo in letture molto contenuto.

La query è composta da tre condizioni: operatore uguale all'utente corrente, tipo uguale a uno dei tipi rilevanti, e timestamp maggiore del cutoff a 90 giorni fa. Questa combinazione richiede un indice composto su Firestore — la console del browser mostra il link per crearlo automaticamente al primo utilizzo.

javascript — query Firestore ferie (lettura singola)
const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
const tipi = [
  'ferie_richiesta', 'ferie_approvata',
  'ferie_rifiutata', 'ferie_annullata'
];

db.collection('activityLog')
  .where('operatore', '==', currentUser)
  .where('tipo', 'in', tipi)
  .where('ts', '>=', cutoff)
  .get()   // una sola lettura, non real-time
  .then(snap => snap.docs.map(d => ({ id: d.id, ...d.data() })));

Perché RTDB per calendario, promemoria e chat

Calendario, promemoria e chat vivono invece sul Realtime Database, che è la scelta giusta per queste tre categorie per motivi opposti: i nodi sono piccoli e la struttura è già ottimizzata per le letture veloci di cui il pannello ha bisogno altrove. Agganciarsi con .on('value') su nodi già esistenti non aggiunge costo significativo.

Per il calendario, la lettura filtra gli eventi dove è presente il campo firedAt (l'appuntamento è scattato) e dove il creatore coincide con l'utente corrente oppure il reparto dell'evento coincide con il reparto dell'utente. Gli appuntamenti degli altri reparti non compaiono.

Per i promemoria, il nodo reminders/{opKey} contiene solo i promemoria personali dell'operatore. Vengono mostrati solo quelli con fired: true — cioè già scattati, non quelli ancora in coda. Il centro notifiche è uno storico, non una lista di cose da fare.

Per la chat, i DM vengono letti da chat/dm/{myKey} con limitToLast(100) filtrando i messaggi non inviati da me. Per i gruppi, il listener si aggancia solo ai reparti di cui l'utente fa parte, leggendo gli ultimi 60 messaggi per gruppo.

💡

Regola pratica: usa .get() Firestore per dati storici che non cambiano dopo la scrittura (log eventi, richieste archiviate). Usa RTDB .on('value') per nodi strutturalmente piccoli dove la reattività ha senso (chat, stato promemoria). Il costo delle letture si vede subito nella console Firebase se sbagli questa distinzione.

Riepilogo sorgenti

Categoria Database Nodo / Query Modalità
🏖️ FerieFirestoreactivityLog (tipo ferie_*).get() — singola
🔵 PermessiFirestoreactivityLog (tipo permesso/malattia).get() — singola
📅 CalendarioRTDBcalendario/.on('value')
⏰ PromemoriaRTDBreminders/{opKey}.on('value')
💬 Chat DMRTDBchat/dm/{myKey}.on('child_added')
💬 Chat GruppiRTDBchat/groups/{gk}.on('child_added')

La UI: drawer, categorie e badge

Il pannello come "portal"

Il drawer del centro notifiche è un pannello che scorre da destra, con overlay scuro semitrasparente. La scelta di posizionarlo come fratello diretto di #mainArea nel DOM — invece che figlio di un container intermedio — è intenzionale: qualsiasi antenato con transform o overflow:hidden rompe position:fixed nei figli. Appendere il panel al body direttamente risolve il problema una volta per tutte.

Filtri su due righe

La barra delle categorie in cima al panel contiene sei chip filtrabili: Tutti, Chat, Calendario, Promemoria, Ferie, Permessi. La prima versione usava overflow-x: auto per permettere lo scroll orizzontale su mobile — ma i chip risultavano invisibili perché uscivano dal viewport senza che l'utente sapesse che poteva scorrere.

La correzione è stata cambiare il contenitore da overflow-x: auto a flex-wrap: wrap: i chip si distribuiscono automaticamente su due righe (tre per riga circa) adattandosi alla larghezza del panel, senza scroll nascosto. Una modifica di una riga, ma la differenza di usabilità è significativa.

Badge "non letti" e localStorage per operatore

Il badge numerico sulla campanella conta gli elementi non ancora aperti dall'operatore. Lo stato "letto/non letto" deve persistere tra sessioni — se chiudo e riapro il pannello, le notifiche già viste non devono ricomparire come nuove — ma deve essere separato per ogni utente sulla stessa macchina.

La soluzione usa localStorage con chiave nc_read_{userKey}: ogni operatore ha il proprio set di ID già letti. Al click su un elemento, l'ID viene aggiunto all'insieme e salvato. Al calcolo del badge, si contano gli elementi la cui chiave non è presente nell'insieme dell'utente corrente.

javascript — stato letto per operatore
function _ncGetRead(userKey) {
  try {
    return new Set(JSON.parse(localStorage.getItem('nc_read_' + userKey) || '[]'));
  } catch(e) { return new Set(); }
}

function _ncMarkRead(userKey, itemId) {
  const set = _ncGetRead(userKey);
  set.add(itemId);
  localStorage.setItem('nc_read_' + userKey, JSON.stringify([...set]));
}

function _ncUpdateBadge() {
  const read = _ncGetRead(state.currentUser);
  const unread = _ncItems.filter(item => !read.has(item.id)).length;
  const badge = document.getElementById('notifBadge');
  if (badge) badge.textContent = unread > 99 ? '99+' : String(unread);
}

Il problema del bootstrap: tre livelli di fallback

Il centro notifiche funzionava perfettamente nei test — aprivo il pannello, facevo login, la campanella si popolava. Ma in produzione, diversi operatori che entrano con "ricordami" (restore della sessione) trovavano il panel vuoto.

La causa era la stessa già incontrata per il calendario: il flusso di _restoreLoginSession() non chiamava _initNotifCenter(). Ma questa volta il problema aveva un'ulteriore complicazione: _initNotifCenter viene definito all'interno del blocco JS del centro notifiche, che viene eseguito dopo il boot del core. Non è garantito che sia disponibile nel momento esatto in cui il restore della sessione completa.

La soluzione: tre livelli di fallback in cascata

  • 1
    Wrapper su _initFCM

    Al login manuale, il core chiama _initFCM(userName). Un wrapper intercetta questa chiamata e avvia anche _initNotifCenter. Funziona nel 100% dei login manuali.

  • 2
    Polling a 500ms × 40 tentativi

    Un intervallo si avvia al caricamento della pagina, controlla ogni 500ms se state.currentUser è valorizzato e se il centro notifiche non è già inizializzato. Copre il restore della sessione, indipendentemente dal momento in cui il core imposta l'utente.

  • 3
    MutationObserver su #navUserChip

    Un osservatore monitora la visibilità del chip con il nome utente nel nav. Quando diventa visibile, avvia l'init se non è già partito. Gestisce anche il logout: quando il chip sparisce (display:none), chiama _destroyNotifCenter() che stacca tutti i listener Firebase e azzera lo stato.

javascript — polling bootstrap per restore sessione
(function _ncBootstrap() {
  var attempts = 0, maxAttempts = 40;
  var iv = setInterval(function() {
    attempts++;
    if (attempts > maxAttempts) { clearInterval(iv); return; }

    if (window.state && state.currentUser && !window._ncInitialized) {
      clearInterval(iv);
      // L'utente è già loggato (restore sessione): avvia subito
      _initNotifCenter(state.currentUser);
    }
  }, 500);
})();
⚠️

Idempotenza obbligatoria: con più percorsi di avvio (login manuale, polling, MutationObserver), la funzione _initNotifCenter deve essere idempotente. Il flag window._ncInitialized blocca l'esecuzione se il centro notifiche è già attivo, evitando listener duplicati su Firebase.

Il posizionamento del bubble: un percorso accidentato

La UI della campanella ha richiesto più iterazioni del previsto — non per complessità tecnica, ma perché ogni posizionamento che sembrava giusto su desktop creava conflitti su mobile.

Le iterazioni

La prima versione inseriva la campanella nella sidebar desktop, come voce di menu. Funzionava su desktop ma su mobile spariva con la sidebar. La seconda versione usava un position:fixed in basso a destra, in basso in fondo alla pagina stile "bubble" — stessa struttura dei pulsanti Chat e AI già presenti. Funzionava, ma visivamente sembrava un quarto pulsante galleggiante dove ce n'erano già due.

La versione finale sposta il bubble in alto a destra (top:18px; right:18px), fuori dall'area dei bubble chat/AI in basso, con z-index 999992 per stare sopra la topbar mobile. Il pattern strutturale replica #chatWrapper e #aiWrapper: un div con position:fixed che contiene il button e la label hover — così il badge rimane position:absolute relativo al button e non al viewport.

Il conflitto con la topbar su mobile

Con top:18px, su iPhone e tablet con schermo sotto i 1024px la campanella finiva esattamente sopra il chip utente e il tasto hamburger della topbar. La topbar ha un'altezza di circa 56-60px (inclusa la safe area). La correzione nella media query max-width:1023px sposta il bubble a top:66px, portandolo sotto la topbar senza toccare nulla del layout desktop.

css — posizionamento bubble e override mobile
/* Desktop: angolo in alto a destra */
#notifWrapper {
  position: fixed;
  top: 18px;
  right: 18px;
  z-index: 999992;
}

/* Mobile/tablet: scende sotto la topbar */
@media (max-width: 1023px) {
  #notifWrapper {
    top: 66px;   /* 60px topbar + 6px margine */
    right: 14px;
  }
}

Takeaway

  1. Firestore .get() per i dati storici, RTDB .on() per i nodi reattivi. Non tutto deve essere real-time. Le richieste di ferie approvate una settimana fa non cambiano più: una lettura singola Firestore è molto più economica di un listener sempre aperto. Riserva il Realtime Database per i dati che cambiano mentre l'utente guarda lo schermo.
  2. Il restore della sessione è un percorso separato nel codice. Chi sviluppa fa quasi sempre login manuale. Chi usa la PWA installata entra quasi sempre col "ricordami". Qualsiasi funzione avviata al login manuale va avviata anche nel restore — e se non puoi modificare il core, il polling con flag di idempotenza è un fallback robusto.
  3. Tre livelli di bootstrap non sono eccessivi. In un sistema dove il timing dell'inizializzazione non è deterministico (restore + script caricati in parallelo), avere un wrapper diretto, un polling di fallback e un MutationObserver come ultima rete di sicurezza significa che il centro notifiche si avvia correttamente in tutti i casi pratici, senza side effect se due livelli scattano quasi simultaneamente.
  4. flex-wrap: wrap invece di overflow-x: auto per filtri su mobile. Lo scroll orizzontale su un panel stretto è invisibile: l'utente non sa che può scorrere. Se i chip sono 6 e il panel è largo 320px, due righe da tre sono sempre preferibili a uno scroll nascosto.
  5. I bubble position:fixed su PWA mobile competono con la topbar. Ogni elemento fisso in alto richiede una media query separata per mobile che lo sposti sotto l'altezza della topbar. Non basta testare su desktop: l'hamburger e il chip utente occupano la stessa area dove si vuole mettere il badge di notifiche.
← Torna al Blog