Il contesto
Il pannello di amministrazione che uso per gestire il team commerciale raccoglie lead da diverse sorgenti. Una di queste è una campagna Facebook per la distribuzione di terminali POS — il progetto [PRODOTTO_POS]. I lead arrivano in un Firebase Realtime Database separato e fino a quel momento erano visibili solo dall'interfaccia web di Firebase Console. L'obiettivo era portarli direttamente nell'Admin, in una sezione dedicata accessibile solo agli utenti autorizzati, con una tabella ordinata per data di compilazione.
La struttura dell'Admin usa già un sistema a tab laterali con dispatcher per sezione.
Aggiungere una voce nuova è un'operazione chirurgica: tre punti nel codice — l'array dei tab,
la mappa delle label e il dispatcher else if — più la funzione di render vera e propria.
Il primo problema: nodo sbagliato nel DB
La prima versione della funzione di caricamento puntava alla radice del database:
// ❌ Punta alla radice — restituisce null const url = 'https://[MY-PROJECT]-default-rtdb.firebasedatabase.app/.json'; // ✅ I dati sono sotto il nodo /leads const url = 'https://[MY-PROJECT]-default-rtdb.firebasedatabase.app/leads.json';
Il database aveva le regole di lettura aperte solo sul nodo leads, non alla radice.
Un fetch su /.json restituiva null perché — con le regole corrette — la
radice non è accessibile. La fix è stata una modifica di tre caratteri nell'URL.
".read": true su /leads non garantisce accesso alla radice.
Testare sempre l'URL diretto nel browser prima di scrivere codice.
Il secondo problema: chiavi di mapping errate
Superato il problema del nodo, la tabella caricava ma mostrava trattini (—) in
ogni colonna. Il record esisteva, ma nessun campo veniva letto correttamente.
La causa era semplice: avevo ipotizzato i nomi delle chiavi invece di verificarli.
La mappatura originale usava nomi "intuitivi" come tipo_pos, quantita,
cliente — mentre il DB conteneva:
// Chiavi reali nel DB Firebase { "cognome": "Roversi", "nome": "Andrea", "email": "email@example.com", "piano": "Smart", "pos_quantita": 1, "pos_tipo": "POS con Stampante", "source": "facebook-lead", "timestamp": "2026-06-09T11:29:19.980Z", "tipo_cliente": "Privati & Startup" }
Una volta corrette le chiavi, tutti i campi venivano letti correttamente. Il timestamp
in formato ISO 8601 viene formattato in italiano con toLocaleDateString('it-IT')
prima di essere mostrato nella colonna Data.
.json del DB direttamente nel browser.
Firebase RTDB restituisce JSON leggibile, con i nomi esatti di tutte le chiavi visibili
in chiaro senza nessun tooling aggiuntivo.
Il terzo problema: l'ID dinamico che dimezzava i record
Con le chiavi corrette la tabella caricava, ma mostrava solo 2 record su 4 presenti nel DB. Nessun errore in console, nessun filtro applicato — i dati c'erano tutti, ma solo metà comparivano. Questo era il bug più sottile.
Causa: setTimeout + ID basato su Date.now()
La funzione renderSection() generava un ID univoco per il container ad ogni chiamata:
function renderSection() { const containerId = 'section_' + Date.now(); // ← ID unico ad ogni render const html = `<div id="${containerId}">Caricamento...</div>`; mainArea.innerHTML = html; // Il timeout scatta DOPO che il DOM è aggiornato setTimeout(() => _loadSection(containerId), 50); }
Il problema si manifestava così: quando l'utente cliccava sul tab [PRODOTTO_POS],
renderSection() veniva chiamata due volte in rapida successione (una volta dal
click, una dal sistema di routing interno che sincronizzava lo stato). La seconda chiamata
sovrascriveva il DOM con un nuovo container — ID diverso — prima che il setTimeout
della prima chiamata scattasse. Quando il timeout scattava, cercava il container con il
vecchio ID: non lo trovava più, e non scriveva nulla. La seconda chiamata invece riusciva,
ma caricava i dati in modo incompleto per un timing sfavorevole con il fetch in corso.
Risultato: record visibili variabili tra i 2 e 4 a seconda della velocità del browser.
Fix: ID fisso + requestAnimationFrame
function renderSection() { const containerId = '_sectionContainer'; // ← ID fisso, stabile const html = `<div id="${containerId}">Caricamento...</div>`; mainArea.innerHTML = html; // requestAnimationFrame garantisce che il DOM sia aggiornato // prima di cercare il container — più affidabile di setTimeout requestAnimationFrame(() => _loadSection(containerId)); }
Due cambiamenti: ID fisso e requestAnimationFrame al posto di setTimeout.
Con l'ID fisso, qualunque chiamata successiva trova sempre lo stesso container nel DOM —
anche se renderSection() viene chiamata più volte, l'ultima sovrascrive
correttamente. Con requestAnimationFrame il callback viene eseguito nel
frame successivo del browser, quando il layout è già stato calcolato e il nodo è
sicuramente nel DOM, senza affidarsi a un delay arbitrario in millisecondi.
setTimeout(fn, N) per aspettare
un aggiornamento del DOM è fragile — il valore giusto di N dipende
dalla velocità del dispositivo. requestAnimationFrame è deterministico:
il callback scatta esattamente dopo che il browser ha finalizzato il rendering del
frame corrente.
Il layout della tabella senza scroll orizzontale
Una volta risolti i bug di caricamento, la tabella usciva dal container orizzontalmente.
Il problema era un mix di white-space: nowrap sulle celle e padding generoso
che impediva al testo di andare a capo. La soluzione definitiva:
/* Layout fisso con larghezze percentuali per colonna */ table { table-layout: fixed; width: 100%; font-size: 12px; } /* Larghezze definite sull'header */ th:nth-child(1) { width: 11%; } /* Cognome */ th:nth-child(2) { width: 10%; } /* Nome */ th:nth-child(3) { width: 20%; } /* Mail */ th:nth-child(4) { width: 18%; } /* Tipo POS */ th:nth-child(5) { width: 7%; } /* Qtà */ th:nth-child(6) { width: 15%; } /* Tipo cliente */ th:nth-child(7) { width: 9%; } /* Piano */ th:nth-child(8) { width: 10%; } /* Data */ td { word-break: break-word; /* Il testo va a capo se necessario */ padding: 7px 8px; } /* Solo Data e Qtà restano su una riga */ td:nth-child(5), td:nth-child(8) { white-space: nowrap; }
Con table-layout: fixed il browser distribuisce le larghezze basandosi
sui valori dichiarati sull'header, invece di calcolarle dal contenuto delle celle.
Il risultato è una tabella prevedibile e stabile, senza scroll orizzontale, dove il
testo lungo (email, tipo POS) va a capo in modo pulito.
Struttura finale della sezione
A regime, la sezione [PRODOTTO_POS] dell'Admin funziona così:
- Visibile solo agli utenti autorizzati — guard nell'array dei tab e nel dispatcher.
- Al click sul tab,
renderSection()costruisce lo scheletro HTML con container a ID fisso e pulsante Aggiorna. requestAnimationFramechiama_loadSection()che esegue il fetch su/leads.json.- I record vengono ordinati per timestamp decrescente (più recente in cima) e inseriti nella tabella.
- Il pulsante Aggiorna chiama di nuovo
_loadSection()con lo stesso ID fisso — il container viene svuotato e riscritto senza re-render dell'intera sezione. - Le colonne mostrano: Cognome, Nome, Mail, Tipo POS, Qtà POS, Tipo cliente, Piano, Data.
/leads
senza autenticazione. Per un progetto pubblico questo non sarebbe accettabile, ma in questo
contesto il pannello Admin è già protetto da login — chi raggiunge la sezione [PRODOTTO_POS]
ha già superato l'autenticazione applicativa.
Lezioni imparate
-
Testare sempre l'URL Firebase nel browser prima di scrivere il fetch.
Aprire
https://<db>.firebasedatabase.app/<nodo>.jsondirettamente rivela in un secondo il nodo corretto, la struttura reale dei dati e le chiavi esatte — senza scrivere una riga di codice. -
Non ipotizzare mai i nomi delle chiavi di un DB esterno.
Nomi "intuitivi" come
tipo_posoquantitasembrano ovvi finché il DB non usapos_tipoepos_quantita. Verificare sempre dal dato reale. -
setTimeoutper aspettare il DOM è fragile;requestAnimationFrameè deterministico. Se il codice usasetTimeout(fn, 50)per "dare tempo al DOM di aggiornarsi", è un campanello d'allarme: quel delay funziona sulla macchina di sviluppo, potrebbe non funzionare su un dispositivo lento o in caso di render multipli ravvicinati. -
Gli ID dinamici nei container sono pericolosi in presenza di re-render.
Un ID basato su
Date.now()o su un contatore genera un nuovo identificatore ad ogni chiamata. Se il codice successivo referenzia l'ID "vecchio", l'elemento non esiste più nel DOM. ID fissi e semantici eliminano questa classe di bug. -
table-layout: fixedcon larghezze percentuali è la soluzione corretta per tabelle responsive. Lasciare che il browser calcoli le larghezze dal contenuto porta a tabelle imprevedibili. Dichiarare esplicitamente le proporzioni garantisce un layout stabile su qualsiasi dimensione del pannello.
The context
The admin panel I use to manage the sales team collects leads from multiple sources. One of them is a Facebook campaign for distributing POS terminals — the [PRODOTTO_POS] project. Leads land in a separate Firebase Realtime Database and until now were only visible through the Firebase Console web interface. The goal was to bring them directly into the Admin, in a dedicated section accessible only to authorized users, with a table sorted by submission date.
The Admin already uses a sidebar tab system with a per-section dispatcher.
Adding a new entry is a surgical operation: three touch points in the code —
the tabs array, the labels map, and the else if dispatcher —
plus the actual render function.
Problem one: wrong DB node
The first version of the load function pointed at the database root:
// ❌ Points to root — returns null const url = 'https://[MY-PROJECT]-default-rtdb.firebasedatabase.app/.json'; // ✅ Data lives under the /leads node const url = 'https://[MY-PROJECT]-default-rtdb.firebasedatabase.app/leads.json';
The database had read rules open only on the leads node, not at the root.
A fetch to /.json returned null because — with proper rules —
the root is not accessible. The fix was a three-character change to the URL.
".read": true on /leads does not grant access to the root.
Always test the direct URL in the browser before writing any code.
Problem two: wrong mapping keys
Past the node problem, the table loaded but showed dashes (—) in every column.
The records existed, but no field was being read correctly.
The reason was simple: I had guessed the key names instead of verifying them.
The original mapping used "intuitive" names like tipo_pos, quantita,
cliente — while the DB actually contained:
// Actual keys in the Firebase DB { "cognome": "Roversi", "nome": "Andrea", "email": "email@example.com", "piano": "Smart", "pos_quantita": 1, "pos_tipo": "POS con Stampante", "source": "facebook-lead", "timestamp": "2026-06-09T11:29:19.980Z", "tipo_cliente": "Privati & Startup" }
Once the keys were corrected, all fields read fine. The ISO 8601 timestamp
gets formatted for display with toLocaleDateString('it-IT')
before appearing in the Date column.
.json URL directly in the browser.
Firebase RTDB returns readable JSON with the exact key names right there in plain text —
no tooling required.
Problem three: the dynamic ID that cut records in half
With correct keys the table loaded, but showed only 2 records out of 4 in the DB. No console errors, no filters applied — all the data was there, but only half showed up. This was the tricky one.
Root cause: setTimeout + Date.now()-based ID
The renderSection() function was generating a unique container ID on every call:
function renderSection() { const containerId = 'section_' + Date.now(); // ← unique ID on every render const html = `<div id="${containerId}">Loading...</div>`; mainArea.innerHTML = html; // Timeout fires AFTER the DOM is updated setTimeout(() => _loadSection(containerId), 50); }
Here's what was happening: when the user clicked the [PRODOTTO_POS] tab,
renderSection() was called twice in quick succession (once from the click,
once from the internal routing system syncing state). The second call overwrote the DOM
with a new container — different ID — before the first call's setTimeout fired.
When the timeout ran, it looked for the container with the old ID: gone, nothing written.
The second call succeeded, but loaded data incompletely due to an unfortunate timing
overlap with the ongoing fetch. Net result: between 2 and 4 records visible depending on browser speed.
Fix: fixed ID + requestAnimationFrame
function renderSection() { const containerId = '_sectionContainer'; // ← fixed, stable ID const html = `<div id="${containerId}">Loading...</div>`; mainArea.innerHTML = html; // requestAnimationFrame guarantees the DOM is updated // before looking up the container — more reliable than setTimeout requestAnimationFrame(() => _loadSection(containerId)); }
Two changes: fixed ID and requestAnimationFrame instead of setTimeout.
With a fixed ID, any subsequent call always finds the same container in the DOM —
even if renderSection() is called multiple times, the last one writes correctly.
With requestAnimationFrame the callback runs in the next browser frame,
when the layout has already been calculated and the node is guaranteed to be in the DOM,
without relying on an arbitrary millisecond delay.
setTimeout(fn, N) to wait for a DOM
update is fragile — the right value of N depends on device speed.
requestAnimationFrame is deterministic: the callback fires exactly after
the browser has finalized rendering the current frame.
Table layout without horizontal scroll
Once the loading bugs were fixed, the table overflowed horizontally out of the container.
The culprit was a combination of white-space: nowrap on cells and generous
padding that prevented text from wrapping. The definitive solution:
/* Fixed layout with percentage column widths */ table { table-layout: fixed; width: 100%; font-size: 12px; } /* Widths declared on the header row */ th:nth-child(1) { width: 11%; } /* Last name */ th:nth-child(2) { width: 10%; } /* First name */ th:nth-child(3) { width: 20%; } /* Email */ th:nth-child(4) { width: 18%; } /* POS type */ th:nth-child(5) { width: 7%; } /* Qty */ th:nth-child(6) { width: 15%; } /* Client type */ th:nth-child(7) { width: 9%; } /* Plan */ th:nth-child(8) { width: 10%; } /* Date */ td { word-break: break-word; /* Long text wraps */ padding: 7px 8px; } /* Only Date and Qty stay single-line */ td:nth-child(5), td:nth-child(8) { white-space: nowrap; }
With table-layout: fixed the browser distributes widths based on the
declared header values, rather than calculating from cell content.
The result is a predictable, stable table with no horizontal scroll,
where long text (email addresses, POS types) wraps cleanly.
Final section structure
In production, the [PRODOTTO_POS] admin section works like this:
- Only visible to authorized users — guard in the tabs array and dispatcher.
- On tab click,
renderSection()builds the HTML skeleton with a fixed-ID container and a Refresh button. requestAnimationFramecalls_loadSection(), which fetches/leads.json.- Records are sorted by descending timestamp (newest first) and inserted into the table.
- The Refresh button calls
_loadSection()again with the same fixed ID — the container is emptied and rewritten without re-rendering the whole section. - Columns: Last name, First name, Email, POS type, POS qty, Client type, Plan, Date.
/leads
without authentication. For a public-facing project this wouldn't be acceptable, but here
the Admin panel is already login-protected — anyone reaching the [PRODOTTO_POS] section has already
passed application-level authentication.
Lessons learned
-
Always test the Firebase URL in the browser before writing fetch code.
Opening
https://<db>.firebasedatabase.app/<node>.jsondirectly reveals the correct node, the real data structure, and exact key names in seconds — without writing a single line of code. -
Never guess the key names of an external DB.
"Intuitive" names like
tipo_posorquantitaseem obvious until the DB usespos_tipoandpos_quantita. Always verify from the actual data. -
setTimeoutto wait for the DOM is fragile;requestAnimationFrameis deterministic. If your code usessetTimeout(fn, 50)to "give the DOM time to update," that's a red flag: the delay works on your dev machine but may not hold on a slower device or when multiple renders fire in quick succession. -
Dynamic container IDs are dangerous when re-renders happen.
An ID based on
Date.now()or a counter generates a new identifier on every call. If subsequent code references the "old" ID, the element no longer exists in the DOM. Fixed, semantic IDs eliminate this entire class of bug. -
table-layout: fixedwith percentage widths is the right solution for responsive tables. Letting the browser calculate widths from content leads to unpredictable tables. Declaring explicit proportions guarantees a stable layout at any panel size.