The Problem: Excel and WhatsApp Chaos
A myPOS payment terminal reseller ran their daily operations—orders, leads, contracts, shipping, HR—across spreadsheets, WhatsApp chats, and emails. No unified view, no real-time status. Andrea Roversi built PanelControl, a 65-file Progressive Web App that centralizes everything into one multi-role app used on desktop, tablet, and phone.
Stack: No Bundler, No Framework
Roversi chose vanilla HTML/CSS/JS with zero bundler. Firebase Realtime Database handles realtime sync, Firestore for queries/analytics, Firebase Auth with custom tokens, and Firebase Storage for files. Backend uses Netlify Functions (ESM, zero npm dependencies). Chart.js for graphs, SheetJS and jsPDF for exports, Google Gemini API for AI, and integrations with Delera/GoHighLevel CRM, Voxloud telephony, and Gmail API.
Custom Rendering Engine
The UI is driven by a homegrown engine: a global state object holds all app data. Every state change calls render() with an 80ms debounce to prevent cascading re-renders, plus renderNow() for synchronous updates like month change. An internal router switches on the current view to call the appropriate render*() function from separate module files.
Key UX guard: before re-render, the engine checks if focus is on an input field. If so, it postpones rendering to avoid destroying the DOM while the user is typing. Exceptions exist for month-select dropdowns and checkboxes that must stay reactive.
> "Non tutti i moduli sono caricati al primo avvio. I file più pesanti sono caricati on-demand solo quando l'utente naviga verso quella sezione"
Heavier modules load on-demand with a retry mechanism for slow mobile networks.
Firebase Optimization: Granular Listeners
The biggest performance win: migrating from on('value') to granular child_added, child_changed, child_removed listeners. Previously, a single write to a node would re-download the entire history on every client. Now only the delta is transmitted. Estimated savings: 40-60 MB per day of Firebase bandwidth. Same pattern on the activity log reduced initial load from 200 to 50 records using once('value') plus child_added for new events—about 75% bandwidth savings.
Authentication: No Shared Firebase Credentials
Login redesigned: a Netlify Function verifies username/password with PBKDF2-SHA256 hashing and server-side rate limiting, then returns a Firebase Custom Token. The client exchanges it for a Firebase Auth session with granular permissions via custom claims, enforced in database security rules. Each operator can have individually enabled permissions from an Admin panel. Access control uses an OR pattern: legacy_hardcoded_list.includes(operator) OR hasAbilitazione(operator, feature). This ensures backward compatibility during migration—new rules never replace old ones outright.
Tricky Modules
Chat: Portal Pattern for position:fixed
Internal chat supports photos, voice messages, documents, and GIFs in a floating bubble UI. The classic problem: position:fixed breaks when an ancestor has a CSS transform. Solution: make the chat a direct sibling of the main container instead of a descendant—DOM structure, not CSS trick.
Email: Polling + Denormalized Schema
The email module polls Gmail API instead of push, using a denormalized schema across two nodes for list speed vs detail speed. All Firebase listeners are granular to avoid downloading entire mailboxes on every change.
Telephony: Hybrid Architecture
Call history uses a two-speed approach: current month stays on Realtime Database with limited listeners and "load more" pagination; past months move to Firestore with block pagination. Searches always route to Firestore to avoid saturating the realtime database.
CRM Reconciliation Under Rate Limits
A scheduled function reconciles each lead with the external CRM one call at a time, respecting API limits. Two-speed strategy: recent leads rechecked every 24 hours, older leads excluded permanently to conserve quota.
Backend: Idempotent, Error-Proof Serverless Functions
All backend functions are pure ESM with zero npm dependencies—lightweight, no supply chain to maintain. Shared patterns: webhook validation via shared secret, deduplication by business key (order number), dual logging to Realtime Database and Firestore, critical alerts via Telegram with per-category throttling. Webhooks always return HTTP 200 even on internal errors to prevent infinite retries from the external CRM.
One operational constraint: Netlify environment variables have a 4KB limit per function. Large credentials like private keys are stored directly in the function file with a manual rotation note, avoiding deploy breaks.
What Roversi Learned
"'Senza framework' non significa 'senza disciplina': significa scrivere a mano le regole che un framework darebbe per scontate"—without framework doesn't mean without discipline; it means writing by hand the rules a framework would take for granted: a sensible debounce, a user interaction guard, a non-destructive permission pattern. And sticking to them as the code grows from one file to sixty-five.
FAQ Highlights
- Why no React/Vue? For a solo developer who knows the codebase deeply, vanilla JS with a custom rendering engine eliminates build steps and dependency updates. The cost is self-imposed discipline.
- How to reduce Firebase bandwidth? Switch from
on('value')to granular listeners—saves up to 70-75% on initial load. - Why Custom Token? Server-side credential verification with hashing and rate limiting, no shared credentials in the client, permissions as custom claims.
- How to handle granular permissions without breaking existing users? OR pattern: legacy list OR new granular permission—introduce fine-grained control without migrating everyone at once.
