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:

javascript
// ❌ 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.

⚠️
Regola Firebase RTDB: le regole di lettura si applicano nodo per nodo. Avere ".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:

javascript
// 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.

💡
Tecnica di debug rapida: se non si può fare fetch direttamente dall'ambiente di sviluppo, basta aprire l'URL .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:

javascript
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

javascript
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.

Regola generale: usare 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:

css
/* 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.
  • requestAnimationFrame chiama _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.
🔒
Nota sulle regole Firebase: il DB usa regole di lettura aperte su /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

  1. Testare sempre l'URL Firebase nel browser prima di scrivere il fetch. Aprire https://<db>.firebasedatabase.app/<nodo>.json direttamente rivela in un secondo il nodo corretto, la struttura reale dei dati e le chiavi esatte — senza scrivere una riga di codice.
  2. Non ipotizzare mai i nomi delle chiavi di un DB esterno. Nomi "intuitivi" come tipo_pos o quantita sembrano ovvi finché il DB non usa pos_tipo e pos_quantita. Verificare sempre dal dato reale.
  3. setTimeout per aspettare il DOM è fragile; requestAnimationFrame è deterministico. Se il codice usa setTimeout(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.
  4. 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.
  5. table-layout: fixed con 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.
← Torna al Blog