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.
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à |
|---|---|---|---|
| 🏖️ Ferie | Firestore | activityLog (tipo ferie_*) | .get() — singola |
| 🔵 Permessi | Firestore | activityLog (tipo permesso/malattia) | .get() — singola |
| 📅 Calendario | RTDB | calendario/ | .on('value') |
| ⏰ Promemoria | RTDB | reminders/{opKey} | .on('value') |
| 💬 Chat DM | RTDB | chat/dm/{myKey} | .on('child_added') |
| 💬 Chat Gruppi | RTDB | chat/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.
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
_initFCMAl 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
#navUserChipUn 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.
(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.
/* 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
-
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. - 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.
- 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.
-
flex-wrap: wrapinvece dioverflow-x: autoper 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. -
I bubble
position:fixedsu 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.
The context
The control panel I use to coordinate the sales team already had FCM push notifications, DM and department group chats, timed reminders with sound, leave and permission requests, and a shared calendar. Each of those systems worked fine on its own — but they were separate islands.
If Andrea wanted to know whether his leave request had been approved, he had to navigate to the leave section. For messages, open chat. For triggered reminders, dig through the global log trying to find his entries among everyone else's. There was no unified collection point.
The idea was simple: a 🔔 bell icon where every operator can see a personal timeline of everything that concerns them — like a LOG but filtered to the current user, browsable even weeks later. No extra push notifications, just a structured archive always available.
Stack: Firebase Realtime Database (chat, calendar, reminders), Firestore (activityLog for leave/permissions), vanilla JavaScript. The panel is a single-page PWA with no framework.
Architecture: six categories, two databases
The first decision was where to read data for each category. The panel already uses both Firebase Realtime Database and Firestore, and the two technologies have different characteristics that make each one a better fit for certain kinds of data.
Why Firestore for leave and permissions
Leave and permission requests already live in Firestore's activityLog collection,
where each document records the operator, event type, timestamp, and status.
These are historical records that don't change in real time: a leave request approved
yesterday won't be modified again. Using .get() (single read) instead of
.onSnapshot() (real-time listener) lets you load the history once when the
panel opens, keeping the read cost very low.
The query has three conditions: operator equals current user, type matches one of the relevant event types, and timestamp is after the 90-day cutoff. This combination requires a composite index in Firestore — the browser console shows a direct link to create it automatically the first time the query runs.
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() // single read, not real-time .then(snap => snap.docs.map(d => ({ id: d.id, ...d.data() })));
Why RTDB for calendar, reminders, and chat
Calendar, reminders, and chat live on the Realtime Database, which is the right call
for these three categories for the opposite reasons: the nodes are small and the structure
is already optimized for the fast reads the panel needs elsewhere. Attaching
a .on('value') listener on already-existing nodes adds no significant cost.
For the calendar, the read filters events that have a firedAt
field (the appointment has triggered) and where the creator matches the current user
or the event's department matches the user's department. Other departments' appointments
don't appear.
For reminders, the reminders/{opKey} node contains only
the operator's personal reminders. Only entries with fired: true are shown —
meaning already triggered, not ones still in the queue. The notification center is a history,
not a to-do list.
For chat, DMs are read from chat/dm/{myKey} with
limitToLast(100), filtering out messages sent by the current user.
For group chats, the listener only attaches to the departments the user belongs to,
reading the last 60 messages per group.
Rule of thumb: use Firestore .get() for historical data that doesn't change after it's written (event logs, archived requests). Use RTDB .on('value') for structurally small nodes where reactivity makes sense (chat, reminder status). The read cost shows up immediately in the Firebase console if you get this distinction wrong.
Data source summary
| Category | Database | Node / Query | Mode |
|---|---|---|---|
| 🏖️ Leave | Firestore | activityLog (type ferie_*) | .get() — single |
| 🔵 Permissions | Firestore | activityLog (type permesso/malattia) | .get() — single |
| 📅 Calendar | RTDB | calendario/ | .on('value') |
| ⏰ Reminders | RTDB | reminders/{opKey} | .on('value') |
| 💬 DM Chat | RTDB | chat/dm/{myKey} | .on('child_added') |
| 💬 Group Chat | RTDB | chat/groups/{gk} | .on('child_added') |
The UI: drawer, categories, and badge
The panel as a "portal"
The notification center is a slide-in panel from the right, with a semi-transparent dark overlay.
The decision to place it as a direct sibling of #mainArea in the DOM —
rather than a child of an intermediate container — is intentional: any ancestor with
transform or overflow:hidden breaks position:fixed
in its children. Appending the panel directly to the body solves the problem for good.
Filters on two rows
The category bar at the top of the panel has six filterable chips: All, Chat, Calendar,
Reminders, Leave, Permissions. The first version used overflow-x: auto
to allow horizontal scrolling on mobile — but chips would vanish off-screen with no
indication that the user could scroll to find them.
The fix was switching the container from overflow-x: auto
to flex-wrap: wrap: the chips automatically wrap onto two rows
(roughly three per row) adapting to the panel width, with no hidden scroll.
A one-line change, but the usability difference is substantial.
"Unread" badge and per-operator localStorage
The numeric badge on the bell counts items the operator hasn't opened yet. The read/unread state needs to persist across sessions — if I close and reopen the panel, previously seen notifications shouldn't show up as new again — but it also needs to be separate for each user on the same machine.
The solution uses localStorage with the key nc_read_{userKey}:
each operator has their own set of already-read IDs. When an item is clicked,
its ID is added to the set and saved. When the badge is computed, it counts items
whose key isn't in the current user's set.
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); }
The bootstrap problem: three fallback levels
The notification center worked perfectly in testing — open the panel, log in, the bell populates. But in production, several operators who entered via "remember me" (session restore) found the panel empty.
The root cause was the same one I'd already hit with the calendar:
the _restoreLoginSession() flow wasn't calling _initNotifCenter().
But this time there was an extra wrinkle: _initNotifCenter is defined inside
the notification center's JS block, which runs after the core boots. There's no guarantee
it exists at the exact moment the session restore completes.
The solution: three cascading fallback levels
-
1
Wrapper on
_initFCMOn manual login, the core calls
_initFCM(userName). A wrapper intercepts this call and also triggers_initNotifCenter. Covers 100% of manual logins. -
2
Polling every 500ms × 40 attempts
An interval starts on page load, checks every 500ms whether
state.currentUseris set and the notification center isn't already initialized. Covers session restore regardless of when the core sets the user. -
3
MutationObserver on
#navUserChipAn observer watches the visibility of the username chip in the nav. When it becomes visible, triggers init if it hasn't run yet. Also handles logout: when the chip disappears (
display:none), calls_destroyNotifCenter(), which detaches all Firebase listeners and resets state.
(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); // User is already logged in (session restore): start immediately _initNotifCenter(state.currentUser); } }, 500); })();
Idempotency is non-negotiable: with multiple startup paths (manual login, polling, MutationObserver), _initNotifCenter must be idempotent. The window._ncInitialized flag blocks execution if the notification center is already running, preventing duplicate Firebase listeners.
Positioning the bell bubble: a bumpy ride
The bell UI took more iterations than expected — not because it was technically complex, but because every placement that looked right on desktop caused conflicts on mobile.
The iterations
Version one put the bell in the desktop sidebar as a menu item.
Worked on desktop, vanished with the sidebar on mobile.
Version two used a position:fixed floating bubble in the bottom-right —
same pattern as the existing Chat and AI buttons. Worked, but visually it looked like
a fourth floating button crowding an area that already had two.
The final version moves the bubble to the top-right (top:18px; right:18px),
away from the bottom chat/AI area, with z-index 999992 to sit above the mobile topbar.
The structural pattern mirrors #chatWrapper and #aiWrapper:
a div with position:fixed containing the button and its hover label —
so the badge stays position:absolute relative to the button, not the viewport.
The conflict with the topbar on mobile
With top:18px, on iPhones and tablets under 1024px wide the bell landed
exactly on top of the user chip and hamburger button. The topbar is roughly 56-60px tall
(including safe area). The fix in the max-width:1023px media query moves
the bubble to top:66px, pushing it below the topbar without touching anything
in the desktop layout.
/* Desktop: top-right corner */ #notifWrapper { position: fixed; top: 18px; right: 18px; z-index: 999992; } /* Mobile/tablet: drop below the topbar */ @media (max-width: 1023px) { #notifWrapper { top: 66px; /* 60px topbar + 6px margin */ right: 14px; } }
Takeaways
-
Firestore
.get()for historical data, RTDB.on()for reactive nodes. Not everything needs to be real-time. Leave requests approved a week ago aren't changing: a single Firestore read is far cheaper than a permanent open listener. Save the Realtime Database for data that changes while the user is actively watching the screen. - Session restore is a separate code path. As a developer you almost always use manual login. As a PWA user you almost always use "remember me." Any function triggered on manual login needs to also fire on restore — and if you can't modify the core, polling with an idempotency flag is a solid fallback.
- Three bootstrap levels isn't overkill. In a system where initialization timing is non-deterministic (restore + scripts loading in parallel), having a direct wrapper, a polling fallback, and a MutationObserver as a last safety net means the notification center starts correctly in all practical cases, with no side effects if two levels fire near-simultaneously.
-
flex-wrap: wrapbeatsoverflow-x: autofor mobile filter chips. Horizontal scroll on a narrow panel is invisible — users don't know they can scroll. If you have 6 chips and the panel is 320px wide, two rows of three always beats hidden horizontal scroll. -
position:fixedbubbles on mobile PWAs compete with the topbar. Every fixed element at the top needs a separate media query for mobile that pushes it below the topbar height. Testing on desktop isn't enough: the hamburger and user chip occupy exactly the area where you want to put a notification badge.