Gateways Analytics Page
Author: Carmine Antonio Bonavoglia Creation Date: 29/10/2025
Last Reviewer: Carmine Antonio Bonavoglia Last Updated: 31/10/2025
Overview
The Gateways Analytics page provides comprehensive analytics for Gateway infrastructure across VTO deployments. It's a sophisticated data aggregation layer with:
- Permission-based filtering (RBAC: Admin vs non-Admin users see different gateway subsets)
- Role-aware data access (non-Admin users filtered by catalogue cross-reference)
- Intelligent caching with concurrent request deduplication
- Multi-level filtering (date ranges, gateway selection with presets)
- Excel report generation (XLSX format with 6+ sections)
- Seven visualization components (Overview, Products/Variants, Device, Pages, Events, Geolocation, Report modal)
- Firestore consumption tracking (snapshot-based read/write monitoring)
Key characteristics:
- Smart default date range (last 7 days, with preset options)
- Role-aware gateway visibility (Admin sees all, non-Admin filtered by catalogue access)
- Dual-filter application (filters buffered locally, applied on "Apply Filters" button)
- Excel sections (Overview, Lines, Device Summary, Browser Usage, Pages, Events, Geo Locations, Variants Details)
- XLSX number formatting (decimal points preserved regardless of locale)
- Total rows (automatically added to each section for quick summaries)
- Non-blocking enrichment (fallback on errors, UI continues)
- Responsive layout (mobile/tablet/desktop with conditional rendering)
Location: /src/pages/Gateways.js
Architecture Overview
Gateways Analytics Page
├─ Redux Layer (Minimal)
│ └─ No Redux state (local state only)
│
├─ State Management (Local)
│ ├─ dateRange [startDate, endDate]
│ ├─ selectedGateway (applied gateways)
│ ├─ selectedFilters (buffered filters before apply)
│ ├─ loading, downloadLoading states
│ ├─ catalogueRefsList (gateway options)
│ ├─ data (filtered session data)
│ ├─ filtersApplied (UI state flag)
│ └─ presetLabel (display date range label)
│
├─ Permission Layer
│ ├─ User role check (secureGetItem("role"))
│ ├─ User catalogue list (secureGetItem("p_list_catalogues"))
│ ├─ Admin: sees all gateways + all session data
│ └─ Non-Admin: sees filtered gateways + filtered session data
│
├─ Caching Layer
│ ├─ gatewaysCache (Map with cacheKey → data)
│ ├─ ongoingFetches (Map with cacheKey → Promise)
│ ├─ snapshotRef (Firestore read tracker)
│ └─ Cache key: `${startDate}-${endDate}-${sorted-gateway-ids}`
│
├─ Data Pipeline
│ ├─ Fetch Gateways collection (permission-filtered)
│ ├─ Fetch Session_Gateway collection (date-filtered)
│ ├─ Apply gateway selection filter
│ ├─ Apply RBAC filtering (if non-Admin)
│ └─ Update UI components
│
├─ UI Sections
│ ├─ Header (with "Create your report" button)
│ ├─ Filters (date range + gateway multi-select)
│ ├─ Overview Card
│ ├─ Products & Variants Analytics
│ ├─ Device Analytics
│ ├─ Pages Analytics (responsive: full width mobile, half on desktop)
│ ├─ Events Analytics (responsive: full width mobile, half on desktop)
│ ├─ Geolocation Map
│ └─ Report Modal (date range, gateway select, section checkboxes)
│
└─ Report Generation
├─ Modal configuration (date range, gateway selection, sections)
├─ Excel workbook (XLSX format, multiple sheets)
├─ Section-specific formatting (totals, percentages, time formats)
├─ Number formatting (decimals with points, 2-decimal precision)
└─ File download via file-saver
Local State Management
Core State Variables
// Display & Loading
const [loading, setLoading] = useState(true);
const [downloadLoading, setDownloadLoading] = useState(false);
const [isFilterOpen, setIsFilterOpen] = useState(false);
// Data
const [data, setData] = useState([]); // Fetched & filtered session data
const [catalogueRefsList, setCatalogueRefsList] = useState([]); // Available gateways
// Filter State (Two-Layer)
const [dateRange, setDateRange] = useState(defaultDateRange); // Current date range
const [selectedGateway, setSelectedGateway] = useState([]); // Applied gateways
const [selectedFilters, setSelectedFilters] = useState({
// Buffered filters
gateways: [],
gatewayObjects: [],
});
const [filtersApplied, setFiltersApplied] = useState(false); // UI flag
// Date Range Display
const [presetLabel, setPresetLabel] = useState("Last 7 days"); // Human-readable label
// Report State
const [showModal, setShowModal] = useState(false);
const [selectedOption, setSelectedOption] = useState([]); // Report gateway selection
const [startDateReport, setStartDateReport] = useState("");
const [endDateReport, setEndDateReport] = useState("");
const [error, setError] = useState("");
const [reportData, setReportData] = useState([]);
const [reportSections, setReportSections] = useState({
overview: false,
deviceAnalytics: false,
pagesAnalytics: false,
eventsAnalytics: false,
geolocation: false,
variantsDetails: false,
});
// UI Totals (for Overview Card)
const [uiTotals, setUiTotals] = useState({
users: 0,
sessions: 0,
newUsers: 0,
avgTime: 0,
avgEngTime: 0,
avgSU: 0,
});
// Clear Filters Button Visibility
const [clearFiltersVisible, setClearFiltersVisible] = useState(false);
Refs (Non-State Tracking)
const gatewaysCache = useRef(new Map()); // Cache: cacheKey → { fetchedData, gatewayOptions }
const snapshotRef = useRef(null); // Firestore consumption snapshot
const ongoingFetches = useRef(new Map()); // In-flight requests: cacheKey → Promise
Default Date Range
const endDate = dayjs().endOf("day"); // Today at 23:59:59
const startDate = dayjs().subtract(7, "day").startOf("day"); // 7 days ago at 00:00:00
const defaultDateRange = [startDate, endDate];
Date Range Presets
const rangePresets = [
{ label: "Today", value: [dayjs().startOf("day"), dayjs().endOf("day")] },
{
label: "Yesterday",
value: [
dayjs().subtract(1, "day").startOf("day"),
dayjs().subtract(1, "day").endOf("day"),
],
},
{
label: "Last Week",
value: [
dayjs().subtract(1, "week").startOf("week"),
dayjs().subtract(1, "week").endOf("week"),
],
},
{
label: "This Week",
value: [dayjs().startOf("week"), dayjs().endOf("day")],
},
{
label: "Last Month",
value: [
dayjs().subtract(1, "month").startOf("month"),
dayjs().subtract(1, "month").endOf("month"),
],
},
{
label: "This Month",
value: [dayjs().startOf("month"), dayjs().endOf("day")],
},
{
label: "Last 3 Months",
value: [
dayjs().subtract(3, "month").startOf("day"),
dayjs().endOf("day"),
],
},
{
label: "Last 6 Months",
value: [
dayjs().subtract(6, "month").startOf("day"),
dayjs().endOf("day"),
],
},
{
label: "This Year",
value: [dayjs().startOf("year"), dayjs().endOf("day")],
},
];
Used for: Quick date range selection, label detection when date range matches preset
Permission-Based Filtering System
Core Concept
Gateways Analytics implements Role-Based Access Control (RBAC) with permission-aware filtering:
User Role Check
├─ Admin
│ └─ Sees all gateways + all session data (no filtering)
│
└─ Non-Admin (Manager, Editor, etc.)
├─ Sees only gateways with at least one catalogue in common
└─ Sees only session data with catalogues in access list
Implementation
Step 1: Retrieve User Permissions
const userRole = secureGetItem("role"); // "Admin" | "Manager" | "Editor" | etc.
const userCataloguesList = secureGetItem("p_list_catalogues") || []; // Array of catalogue IDs
Step 2: Filter Gateways (for visibility)
let filteredGateways = availableGateways;
if (userRole !== "Admin") {
// Non-Admin: keep only gateways with at least one shared catalogue
filteredGateways = availableGateways.filter((gateway) => {
const gatewayCatalogues = gateway.list_catalogues || [];
// Check for catalogue intersection
return gatewayCatalogues.some((catalogueId) =>
userCataloguesList.includes(catalogueId)
);
});
}
// Result: gatewayOptions array for dropdown
const gatewayOptions = filteredGateways.map((gateway) => ({
value: gateway.id,
label: gateway.nameGateway,
catalogues: gateway.list_catalogues || [],
}));
Step 3: Filter Session Data (for access)
let fetchedData = querySnapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
// Apply gateway filter (if user selected specific gateways)
if (selectedGateways.length > 0) {
// Get catalogues from selected gateways
const selectedCatalogues = gatewayOptions
.filter((gateway) => selectedGateways.includes(gateway.value))
.flatMap((gateway) => gateway.catalogues);
// Filter sessions by catalogue
fetchedData = fetchedData.filter((item) =>
item.catalogue_refs_list?.some((ref) =>
selectedCatalogues.includes(ref)
)
);
} else {
// No gateway selected: for non-Admin users, apply global catalogue filter
if (userRole !== "Admin") {
fetchedData = fetchedData.filter((item) =>
item.catalogue_refs_list?.some((ref) =>
userCataloguesList.includes(ref)
)
);
}
// Admin users see all data when no gateway selected
}
Permission Flow Diagram
User visits Analytics
↓
Retrieve (userRole, userCataloguesList)
↓
Fetch ALL gateways from Gateways collection
↓
Filter gateways by RBAC
├─ Admin: all gateways
└─ Non-Admin: gateways with shared catalogues
↓
Display in dropdown (gatewayOptions)
↓
User selects gateway(s)
↓
Fetch Session_Gateway data
↓
Filter by selected gateway(s) → extract catalogue IDs
↓
Filter sessions: only those with matching catalogues
↓
Display filtered data in UI
Caching System (Concurrent Request Deduplication)
Cache Key Generation
const cacheKey = `${startDate || "null"}-${endDate || "null"}-${selectedGateways
.sort()
.join(",")}`;
// Example: "2025/10/22-2025/10/29-gateway-1-gateway-2"
Sort requirement: Ensures same gateways in different order produce same key
Three-Layer Caching Strategy
Layer 1: Check Persistent Cache
if (gatewaysCache.current.has(cacheKey)) {
console.log(`📦 Using cached data (key: ${cacheKey.substring(0, 50)}...)`);
const cachedData = gatewaysCache.current.get(cacheKey);
setData(cachedData.fetchedData);
setCatalogueRefsList(cachedData.gatewayOptions);
setLoading(false);
return;
}
Layer 2: Check In-Flight Requests
if (ongoingFetches.current.has(cacheKey)) {
console.log(
`⏳ Fetch already in progress, waiting... (key: ${cacheKey.substring(
0,
50
)}...)`
);
const cachedData = await ongoingFetches.current.get(cacheKey);
setData(cachedData.fetchedData);
setCatalogueRefsList(cachedData.gatewayOptions);
setLoading(false);
return;
}
Benefit: Multiple simultaneous requests for same data await same Promise
Layer 3: Create New Fetch
const fetchPromise = (async () => {
// ... fetch logic ...
const result = { fetchedData, gatewayOptions };
gatewaysCache.current.set(cacheKey, result);
return result;
})();
// Register IMMEDIATELY before await
ongoingFetches.current.set(cacheKey, fetchPromise);
Fetch Gateways Workflow
Main Function: fetchGateways(startDate, endDate, selectedGateways)
const fetchGateways = async (startDate, endDate, selectedGateways = []) => {
try {
// 1. Capture Firestore snapshot
snapshotRef.current = tracker.snapshot();
setLoading(true);
// 2. Generate cache key
const cacheKey = `${startDate || "null"}-${
endDate || "null"
}-${selectedGateways.sort().join(",")}`;
// 3. Check persistent cache
if (gatewaysCache.current.has(cacheKey)) {
const cachedData = gatewaysCache.current.get(cacheKey);
setData(cachedData.fetchedData);
setCatalogueRefsList(cachedData.gatewayOptions);
setLoading(false);
return;
}
// 4. Check in-flight requests
if (ongoingFetches.current.has(cacheKey)) {
const cachedData = await ongoingFetches.current.get(cacheKey);
setData(cachedData.fetchedData);
setCatalogueRefsList(cachedData.gatewayOptions);
setLoading(false);
return;
}
console.log(
`🔥 Firebase call in progress (key: ${cacheKey.substring(
0,
50
)}...)`
);
// 5. Create fetch Promise
const fetchPromise = (async () => {
// 5a. Retrieve user permissions
const userRole = secureGetItem("role");
const userCataloguesList = secureGetItem("p_list_catalogues") || [];
// 5b. Fetch available gateways from Gateways collection
const availableGateways = await fetchGatewaysFromCollection();
// 5c. Filter gateways by RBAC
let filteredGateways = availableGateways;
if (userRole !== "Admin") {
filteredGateways = availableGateways.filter((gateway) => {
const gatewayCatalogues = gateway.list_catalogues || [];
return gatewayCatalogues.some((catalogueId) =>
userCataloguesList.includes(catalogueId)
);
});
}
// 5d. Fetch Session_Gateway data (with date filtering)
let gatewaysQuery = collection(db, "Session_Gateway");
if (startDate && endDate) {
gatewaysQuery = query(
gatewaysQuery,
where("session_start_date", ">=", startDate),
where("session_start_date", "<=", endDate)
);
}
const querySnapshot = await trackedGetDocs(
gatewaysQuery,
"Session_Gateway"
);
let fetchedData = querySnapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
// 5e. Prepare gateway options (only filtered gateways)
const gatewayOptions = filteredGateways.map((gateway) => ({
value: gateway.id,
label: gateway.nameGateway,
catalogues: gateway.list_catalogues || [],
}));
// 5f. Apply gateway selection filter
if (selectedGateways.length > 0) {
// Extract catalogues from selected gateways
const selectedCatalogues = gatewayOptions
.filter((gateway) =>
selectedGateways.includes(gateway.value)
)
.flatMap((gateway) => gateway.catalogues);
// Filter sessions by catalogue
fetchedData = fetchedData.filter((item) =>
item.catalogue_refs_list?.some((ref) =>
selectedCatalogues.includes(ref)
)
);
} else {
// No gateway selected: apply user's catalogue filter (non-Admin only)
if (userRole !== "Admin") {
fetchedData = fetchedData.filter((item) =>
item.catalogue_refs_list?.some((ref) =>
userCataloguesList.includes(ref)
)
);
}
}
// 5g. Cache the result
const result = { fetchedData, gatewayOptions };
gatewaysCache.current.set(cacheKey, result);
return result;
})();
// 6. Register Promise for deduplication
ongoingFetches.current.set(cacheKey, fetchPromise);
// 7. Await and set data
try {
const result = await fetchPromise;
setData(result.fetchedData);
setCatalogueRefsList(result.gatewayOptions);
} catch (error) {
console.error("Error retrieving data:", error);
} finally {
// 8. Cleanup
ongoingFetches.current.delete(cacheKey);
setLoading(false);
// 9. Show Firestore consumption diff
if (snapshotRef.current) {
tracker.showDiff(
snapshotRef.current,
"Firebase Consumo - Gateways"
);
}
}
} catch (error) {
console.error("Error in fetchGateways:", error);
setLoading(false);
}
};
Dual-Filter Pattern (Buffered Filters)
Why Buffered Filters?
Without buffering, every filter change triggers immediate fetch (poor UX). Solution: buffer locally, apply on button click.
Two-Layer Filter System
Layer 1: Buffered Local State
const [selectedFilters, setSelectedFilters] = useState({
gateways: [],
gatewayObjects: [],
});
Layer 2: Applied Filters
const [selectedGateway, setSelectedGateway] = useState([]);
Handler: handleGatewayChange(values)
const handleGatewayChange = (values) => {
// Find gateway objects for selected values
const selectedGatewayObjects = values.map((value) => {
const gateway = catalogueRefsList.find((g) => g.value === value);
return (
gateway || { value, label: `Gateway ${value.substring(0, 8)}...` }
);
});
// Update BUFFERED filters only
setSelectedFilters((prev) => ({
...prev,
gateways: values,
gatewayObjects: selectedGatewayObjects,
}));
// Don't apply immediately
setClearFiltersVisible(values.length > 0 || dateRange !== defaultDateRange);
};
User sees: Filter UI updates immediately, but data doesn't change
Handler: handleApplyFilters()
const handleApplyFilters = () => {
setIsFilterOpen(false);
setFiltersApplied(true);
// Now apply buffered filters
setSelectedGateway(selectedFilters.gateways);
const [startDate, endDate] = dateRange || [];
fetchGateways(
startDate?.format("YYYY/MM/DD"),
endDate?.format("YYYY/MM/DD"),
selectedFilters.gateways
);
setClearFiltersVisible(
selectedFilters.gateways.length > 0 || dateRange !== defaultDateRange
);
};
Result: Fetch triggered only once when "Apply Filters" clicked
Handler: handleDateChange(dates)
const handleDateChange = (dates) => {
if (dates && dates.length === 2) {
const [startDate, endDate] = dates;
setDateRange(dates);
setClearFiltersVisible(true);
// Date changes trigger IMMEDIATE fetch (with current selected gateways)
fetchGateways(
startDate.format("YYYY/MM/DD"),
endDate.format("YYYY/MM/DD"),
selectedGateway
);
// Update preset label
const label = findPresetLabel(dates, rangePresets);
setPresetLabel(
label ||
`${startDate.format("DD/MM/YYYY")} - ${endDate.format(
"DD/MM/YYYY"
)}`
);
} else {
// Reset to default
setDateRange(defaultDateRange);
setPresetLabel("Last 7 days");
setClearFiltersVisible(selectedFilters.gateways.length > 0);
fetchGateways(
startDate.format("YYYY/MM/DD"),
endDate.format("YYYY/MM/DD"),
selectedGateway
);
}
};
Design Decision: Date changes apply immediately (different from gateway filters)
Clear Filters Handlers
handleClearFilter() - Clear Gateways Only
const handleClearFilter = () => {
// Reset only gateway filters, KEEP dates
setSelectedGateway([]);
setSelectedFilters({
gateways: [],
gatewayObjects: [],
});
setClearFiltersVisible(false);
setFiltersApplied(false);
// Fetch with empty gateway selection but same dates
const [currentStartDate, currentEndDate] = dateRange || [];
fetchGateways(
currentStartDate?.format("YYYY/MM/DD"),
currentEndDate?.format("YYYY/MM/DD"),
[]
);
};
handleClearAllFilters() - Clear Everything
const handleClearAllFilters = () => {
// Reset dates + gateways
setDateRange(defaultDateRange);
setSelectedGateway([]);
setSelectedFilters({
gateways: [],
gatewayObjects: [],
});
setClearFiltersVisible(false);
setPresetLabel("Last 7 days");
setFiltersApplied(false);
// Fetch with default dates + no gateways
fetchGateways(startDate.format("YYYY/MM/DD"), endDate.format("YYYY/MM/DD"));
};
Report Modal System
Modal State
const [showModal, setShowModal] = useState(false);
const [selectedOption, setSelectedOption] = useState([]); // Selected gateways
const [startDateReport, setStartDateReport] = useState("");
const [endDateReport, setEndDateReport] = useState("");
const [error, setError] = useState("");
const [downloadLoading, setDownloadLoading] = useState(false);
const [reportSections, setReportSections] = useState({
overview: false,
deviceAnalytics: false,
pagesAnalytics: false,
eventsAnalytics: false,
geolocation: false,
variantsDetails: false,
});
Date Validation Handlers
Start Date Handler:
const handleStartDateChange = (date) => {
if (!date) {
setStartDateReport(null);
setError("");
return;
}
const value = date.format("YYYY-MM-DD");
if (endDateReport && dayjs(value).isAfter(dayjs(endDateReport))) {
setError("Start date cannot be later than end date.");
} else {
setStartDateReport(value);
setError("");
}
};
End Date Handler:
const handleEndDateChange = (date) => {
if (!date) {
setEndDateReport(null);
setError("");
return;
}
const value = date.format("YYYY-MM-DD");
if (startDateReport && dayjs(value).isBefore(dayjs(startDateReport))) {
setError("End date cannot be earlier than start date.");
} else {
setEndDateReport(value);
setError("");
}
};
Gateway Selection Handlers
Handle Option Change (checkbox toggle):
const handleOptionsChange = (e) => {
const { value, checked } = e.target;
setSelectedOption((prevSelectedOption) => {
let updatedSelectedOption;
if (checked) {
updatedSelectedOption = [...prevSelectedOption, value];
} else {
updatedSelectedOption = prevSelectedOption.filter(
(gateway) => gateway !== value
);
}
// If "all" was selected but item unchecked, remove "all"
if (prevSelectedOption.includes("all") && !checked) {
updatedSelectedOption = updatedSelectedOption.filter(
(item) => item !== "all"
);
}
// If all individual gateways selected, add "all"
if (updatedSelectedOption.length === catalogueRefsList.length) {
return ["all", ...updatedSelectedOption];
}
return updatedSelectedOption;
});
};
Handle Select All (checkbox master):
const handleSelectAll = (e) => {
if (e.target.checked) {
// Select all gateways + "all" marker
setSelectedOption(["all", ...catalogueRefsList.map((g) => g.value)]);
} else {
// Deselect all
setSelectedOption([]);
}
};
Report Section Selection
const handleReportSectionChange = (e) => {
const { name, checked } = e.target;
if (name === "all") {
// Toggle all sections
setReportSections({
overview: checked,
deviceAnalytics: checked,
pagesAnalytics: checked,
eventsAnalytics: checked,
geolocation: checked,
variantsDetails: checked,
});
} else {
// Toggle specific section
setReportSections((prev) => ({
...prev,
[name]: checked,
}));
}
};
Form Validation
const isAllSectionsSelected = () => {
return Object.values(reportSections).every((value) => value === true);
};
const isAnySectionSelected = () => {
return Object.values(reportSections).some((value) => value === true);
};
const isFormValid = () => {
return startDateReport && endDateReport && !error && isAnySectionSelected();
};
Report Download Handler
handleReportDownload() - Excel Generation
const handleReportDownload = async () => {
setDownloadLoading(true);
try {
let filteredData = reportData;
// 1. Fetch data if not already loaded
if (!Array.isArray(filteredData) || filteredData.length === 0) {
filteredData = await fetchFilteredDataForReport();
}
if (!Array.isArray(filteredData) || filteredData.length === 0) {
messageApi.warning("No data available for the report.");
return;
}
// 2. Create workbook
const workbook = XLSX.utils.book_new();
// 3. Calculate data for each section (only if selected)
const overviewData = reportSections.overview
? calculateGatewaysOverview(filteredData)
: null;
const linesGlassesData = await calculateLinesGlassesVariants(
filteredData
);
const deviceAnalyticsData = reportSections.deviceAnalytics
? calculateDeviceAnalytics(filteredData, "All Devices")
: null;
const pagesAnalyticsData = reportSections.pagesAnalytics
? calculatePagesAnalytics(filteredData)
: null;
const eventsAnalyticsData = reportSections.eventsAnalytics
? calculateEventsAnalytics(filteredData)
: null;
const geolocationData = reportSections.geolocation
? await calculateGeolocationData(filteredData)
: null;
const variantsDetailsData = reportSections.variantsDetails
? await calculateVariantsDetails(filteredData)
: null;
// 4. Helper function to create sheets with proper formatting
const createSheet = (data, sheetName) => {
if (!data || (Array.isArray(data) && data.length === 0)) {
console.warn(`Sheet "${sheetName}" not created: empty data.`);
return;
}
// Process numeric data for Excel compatibility
const processedData = data.map((row) => {
const newRow = { ...row };
Object.keys(newRow).forEach((key) => {
if (
typeof newRow[key] === "number" &&
!Number.isInteger(newRow[key])
) {
newRow[key] = newRow[key].toFixed(2); // 2 decimals
}
});
return newRow;
});
// Create worksheet
const ws = XLSX.utils.json_to_sheet(processedData);
// Apply formatting for decimal columns
if (sheetName === "Overview") {
const headers = Object.keys(processedData[0] || {});
["Avg Time", "Avg Eng Time", "Avg S/U"].forEach((column) => {
const colIndex = headers.indexOf(column);
if (colIndex >= 0) {
const colLetter = String.fromCharCode(65 + colIndex);
for (let i = 0; i < processedData.length; i++) {
const cellRef = `${colLetter}${i + 2}`;
if (!ws[cellRef]) ws[cellRef] = {};
ws[cellRef].z = "0.00"; // Format as number with 2 decimals
}
}
});
}
XLSX.utils.book_append_sheet(workbook, ws, sheetName);
};
// 5. Add sections to workbook
// Overview section
if (
reportSections.overview &&
Array.isArray(overviewData) &&
overviewData.length > 0
) {
const formattedOverviewData = overviewData.map((row) => ({
...row,
"Avg S/U":
typeof row["Avg S/U"] === "number"
? parseFloat(row["Avg S/U"].toFixed(2))
: parseFloat(parseFloat(row["Avg S/U"]).toFixed(2)),
}));
// Add totals row
const totalRow = {
Gateway: "Totals",
Users: uiTotals.users,
Sessions: uiTotals.sessions,
"New Users": uiTotals.newUsers,
"Avg Time": uiTotals.avgTime,
"Avg Eng Time": uiTotals.avgEngTime,
"Avg S/U": uiTotals.avgSU,
};
const overviewWithTotal = [...formattedOverviewData, totalRow];
createSheet(overviewWithTotal, "Overview");
}
// Lines section (always included for internal calculations)
if (
linesGlassesData &&
Array.isArray(linesGlassesData.lines) &&
linesGlassesData.lines.length > 0
) {
const totalVisualisations = linesGlassesData.lines.reduce(
(sum, row) => sum + (row.visualisations || 0),
0
);
const totalSessions = linesGlassesData.lines.reduce(
(sum, row) => sum + (row.sessions || 0),
0
);
const totalRow = {
line: "TOTALS",
visualisations: totalVisualisations,
sessions: totalSessions,
};
const linesWithTotal = [...linesGlassesData.lines, totalRow];
createSheet(linesWithTotal, "Lines");
}
// Device Analytics section
if (reportSections.deviceAnalytics && deviceAnalyticsData) {
// Device Summary
if (
Array.isArray(deviceAnalyticsData.summary) &&
deviceAnalyticsData.summary.length > 0
) {
const totalDevices =
deviceAnalyticsData.summary.find(
(item) => item.label === "All Devices"
)?.total || 0;
const deviceSummaryWithPercentages =
deviceAnalyticsData.summary.map((item) => ({
"Device Category": item.label,
Count: item.total,
Percentage:
totalDevices > 0
? `${(
(item.total / totalDevices) *
100
).toFixed(1)}%`
: "0.0%",
}));
const totalRow = {
"Device Category": "TOTALS",
Count: totalDevices,
Percentage: "100.0%",
};
deviceSummaryWithPercentages.push(totalRow);
createSheet(deviceSummaryWithPercentages, "Device Summary");
}
// Browser Usage
if (
Array.isArray(deviceAnalyticsData.browserUsage) &&
deviceAnalyticsData.browserUsage.length > 0
) {
const totalBrowserSessions =
deviceAnalyticsData.browserUsage.reduce(
(sum, item) => sum + item.count,
0
);
const browserUsageWithPercentages =
deviceAnalyticsData.browserUsage.map((item) => ({
Browser: item.name,
Count: item.count,
Percentage:
totalBrowserSessions > 0
? `${(
(item.count / totalBrowserSessions) *
100
).toFixed(1)}%`
: "0.0%",
}));
const totalRow = {
Browser: "TOTALS",
Count: totalBrowserSessions,
Percentage: "100.0%",
};
browserUsageWithPercentages.push(totalRow);
createSheet(browserUsageWithPercentages, "Browser Usage");
}
}
// Pages Analytics section
if (
reportSections.pagesAnalytics &&
Array.isArray(pagesAnalyticsData) &&
pagesAnalyticsData.length > 0
) {
const formattedPagesData = pagesAnalyticsData.map((row) => ({
...row,
viewingPercentage:
typeof row.viewingPercentage === "string"
? parseFloat(row.viewingPercentage)
: row.viewingPercentage,
}));
const totalVisualisations = formattedPagesData.reduce(
(sum, row) => sum + (row.visualisations || 0),
0
);
const totalViewingTime = formattedPagesData.reduce((sum, row) => {
const timeStr = row.viewingTotalTime || "0:00";
const parts = timeStr.split(":");
const minutes = parseInt(parts[0] || 0);
const seconds = parseInt(parts[1] || 0);
return sum + (minutes * 60 + seconds);
}, 0);
const formatTime = (totalSeconds) => {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = Math.floor(totalSeconds % 60);
if (hours > 0) {
return `${hours}:${minutes
.toString()
.padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
return `${minutes}:${secs.toString().padStart(2, "0")}`;
};
const totalRow = {
page: "TOTALS",
visualisations: totalVisualisations,
viewingPercentage: 100.0,
viewingTotalTime: formatTime(totalViewingTime),
viewingAvgTime:
totalVisualisations > 0
? formatTime(
Math.floor(totalViewingTime / totalVisualisations)
)
: "0:00",
};
formattedPagesData.push(totalRow);
createSheet(formattedPagesData, "Pages Analytics");
}
// Events Analytics section
if (
reportSections.eventsAnalytics &&
Array.isArray(eventsAnalyticsData) &&
eventsAnalyticsData.length > 0
) {
const formattedEventsData = eventsAnalyticsData.map((row) => ({
...row,
avgEventPerUser:
typeof row.avgEventPerUser === "string"
? parseFloat(row.avgEventPerUser)
: row.avgEventPerUser,
}));
const totalEventCount = formattedEventsData.reduce(
(sum, row) => sum + (row.count || 0),
0
);
const totalUniqueUsers = new Set();
eventsAnalyticsData.forEach((row) => {
if (row.totalUsers) {
for (let i = 0; i < row.totalUsers; i++) {
totalUniqueUsers.add(`${row.event}_user_${i}`);
}
}
});
const totalUsers = totalUniqueUsers.size;
const avgEventPerUser =
totalUsers > 0 ? (totalEventCount / totalUsers).toFixed(2) : 0;
const totalRow = {
event: "TOTALS",
count: totalEventCount,
totalUsers: totalUsers,
avgEventPerUser: parseFloat(avgEventPerUser),
};
formattedEventsData.push(totalRow);
createSheet(formattedEventsData, "Events Analytics");
}
// Geolocation section
if (reportSections.geolocation && geolocationData) {
if (
Array.isArray(geolocationData.locations) &&
geolocationData.locations.length > 0
) {
const cityCountryMap = new Map();
geolocationData.locations.forEach((item) => {
const locationParts = item.location.split(" - ");
const country = locationParts[0] || "Unknown";
const city =
locationParts.length > 2
? locationParts[locationParts.length - 1]
: locationParts.length > 1
? locationParts[1]
: "Unknown";
const key = `${country}|${city}`;
if (cityCountryMap.has(key)) {
const existingCount = cityCountryMap.get(key).Count;
cityCountryMap.set(key, {
Country: country,
City: city,
Count: existingCount + item.count,
});
} else {
cityCountryMap.set(key, {
Country: country,
City: city,
Count: item.count,
});
}
});
const processedGeoData = Array.from(
cityCountryMap.values()
).sort((a, b) => b.Count - a.Count);
const totalGeoCount = processedGeoData.reduce(
(sum, item) => sum + (item.Count || 0),
0
);
const totalRow = {
Country: "TOTALS",
City: "ALL LOCATIONS",
Count: totalGeoCount,
};
processedGeoData.push(totalRow);
createSheet(processedGeoData, "Geo Locations");
}
}
// Variants Details section
if (
reportSections.variantsDetails &&
Array.isArray(variantsDetailsData) &&
variantsDetailsData.length > 0
) {
const totalVisualisations = variantsDetailsData.reduce(
(sum, row) => sum + (row.Visualizations || 0),
0
);
const totalSessions = variantsDetailsData.reduce(
(sum, row) => sum + (row.Sessions || 0),
0
);
const totalTime = variantsDetailsData.reduce((sum, row) => {
const timeStr = row.Time || "0:00";
const parts = timeStr.split(":");
if (parts.length === 3) {
const hours = parseInt(parts[0] || 0);
const minutes = parseInt(parts[1] || 0);
const seconds = parseInt(parts[2] || 0);
return sum + (hours * 3600 + minutes * 60 + seconds);
} else if (parts.length === 2) {
const minutes = parseInt(parts[0] || 0);
const seconds = parseInt(parts[1] || 0);
return sum + (minutes * 60 + seconds);
}
return sum;
}, 0);
const formatTime = (totalSeconds) => {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = Math.floor(totalSeconds % 60);
if (hours > 0) {
return `${hours}:${minutes
.toString()
.padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
return `${minutes}:${secs.toString().padStart(2, "0")}`;
};
const totalRow = {
"Variant Name": "TOTALS",
SKU: "ALL VARIANTS",
"Frame Color": "ALL COLORS",
Visualizations: totalVisualisations,
Time: formatTime(totalTime),
Sessions: totalSessions,
};
const variantsDetailsWithTotal = [...variantsDetailsData, totalRow];
createSheet(variantsDetailsWithTotal, "Variants Details");
}
// 6. Save file
const excelBuffer = XLSX.write(workbook, {
bookType: "xlsx",
type: "array",
});
const dataBlob = new Blob([excelBuffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8",
});
saveAs(dataBlob, `Report_${startDateReport}_${endDateReport}.xlsx`);
} catch (error) {
console.error("Error during report download:", error);
messageApi.error("Error downloading the report.");
} finally {
setDownloadLoading(false);
setShowModal(false);
}
};
Excel Report Sections
Overview Sheet
Columns: Gateway, Users, Sessions, New Users, Avg Time, Avg Eng Time, Avg S/U
Totals Row: Aggregates from uiTotals state (set by GatewaysOverviewCard callback)
Formatting:
// Avg S/U formatted as number with 2 decimals
ws[cellRef].z = "0.00";
Lines Sheet
Columns: line, visualisations, sessions
Totals Row: Sum of visualisations and sessions
Always included (even if not selected, used for internal calculations)
Device Summary Sheet
Columns: Device Category, Count, Percentage
Calculation: (deviceCount / totalDevices) * 100
Totals Row: 100.0%
Browser Usage Sheet
Columns: Browser, Count, Percentage
Calculation: (browserCount / totalSessions) * 100
Totals Row: 100.0%
Pages Analytics Sheet
Columns: page, visualisations, viewingPercentage, viewingTotalTime, viewingAvgTime
Totals Row: Aggregated visualisations, 100.0% viewing, formatted total time
Time Format: "MM:SS" or "HH:MM:SS" if hours > 0
Events Analytics Sheet
Columns: event, count, totalUsers, avgEventPerUser
Totals Row: Total event count, total unique users, average events per user
Formatting: avgEventPerUser as number with 2 decimals
Geo Locations Sheet
Columns: Country, City, Count
Processing: Aggregates by country-city combination, sorted by count descending
Totals Row: Total location count across all locations
Variants Details Sheet
Columns: Variant Name, SKU, Frame Color, Visualizations, Time, Sessions
Totals Row: Sum of all metrics
Time Format: "MM:SS" or "HH:MM:SS" if hours > 0
UI Components Integration
GatewaysOverviewCard
<GatewaysOverviewCard
loading={loading}
aggregateList={data}
calculationFunction={memoizedCalculateGatewaysOverview}
onDataCalculated={(totals) => setUiTotals(totals)}
/>
Responsibility: Calculate overview totals, trigger callback with uiTotals
UI Totals Structure:
{
users: number,
sessions: number,
newUsers: number,
avgTime: string (MM:SS format),
avgEngTime: string (MM:SS format),
avgSU: number (with decimals)
}
LinesGlassesVariantsAnalytics
<LinesGlassesVariantsAnalytics
data={data}
loading={loading}
clearFiltersVisible={clearFiltersVisible}
onResetFilters={handleClearFilter}
calculationFunction={memoizedCalculateLinesGlassesVariants}
/>
Displays: Products (lines) and variants analytics
DeviceAnalytics
<DeviceAnalytics
data={data}
loading={loading}
clearFiltersVisible={clearFiltersVisible}
onResetFilters={handleClearFilter}
calculationFunction={memoizedCalculateDeviceAnalytics}
/>
Displays: Device, browser, OS breakdown
PagesAnalytics
<PagesAnalytics
data={data}
loading={loading}
clearFiltersVisible={clearFiltersVisible}
onResetFilters={handleClearFilter}
calculationFunction={memoizedCalculatePagesAnalytics}
/>
Displays: Page viewing time and visualisations
EventsAnalytics
<EventsAnalytics
data={data}
loading={loading}
clearFiltersVisible={clearFiltersVisible}
onResetFilters={handleClearFilter}
calculationFunction={memoizedCalculateEventsAnalytics}
/>
Displays: Event tracking data
GeolocationMap
<GeolocationMap
data={data}
loading={loading}
clearFiltersVisible={clearFiltersVisible}
onResetFilters={handleClearFilter}
calculationFunction={memoizedCalculateGeolocationData}
/>
Displays: Map with geolocation data
Responsive Design
Tablet/Mobile vs Desktop
const isTabletOrMobile = useMediaQuery({ query: "(max-width: 768px)" });
// Pages & Events Analytics layout change
{isTabletOrMobile ? (
<>
<Row gutter={[16, 16]}>
<Col span={24}>
<PagesAnalytics ... />
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={24}>
<EventsAnalytics ... />
</Col>
</Row>
</>
) : (
<Row gutter={[16, 16]}>
<Col span={12}>
<PagesAnalytics ... />
</Col>
<Col span={12}>
<EventsAnalytics ... />
</Col>
</Row>
)}
Desktop: 2-column layout (half width each)
Mobile: Full-width stacked layout
Firestore Consumption Tracking
Initialization
useEffect(() => {
tracker.start("Gateways");
return () => {
tracker.end();
};
}, []);
Per-Fetch Tracking
// Before fetch
snapshotRef.current = tracker.snapshot();
// After fetch
tracker.showDiff(snapshotRef.current, "Firebase Consumo - Gateways");
Output:
Firebase Consumo - Gateways
──────────────────────────
Reads before: 50
Reads after: 85
Diff: +35 reads
Writes before: 0
Writes after: 0
Diff: +0 writes
Data Structures
Gateway Object
{
id: string,
nameGateway: string,
list_catalogues: string[], // Array of catalogue IDs
// ... other fields
}
Session_Gateway Document
{
id: string,
session_start_date: string (YYYY/MM/DD format),
catalogue_refs_list: string[], // Array of catalogue IDs
// ... session metrics (device, pages, events, geolocation)
}
Gateway Option (for dropdown)
{
value: string, // Gateway ID
label: string, // Gateway name
catalogues: string[] // List of associated catalogues
}
Utility Functions
isSameDay(date1, date2)
const isSameDay = (date1, date2) => {
if (!date1 || !date2) return false;
return (
date1.year() === date2.year() &&
date1.month() === date2.month() &&
date1.date() === date2.date()
);
};
Purpose: Exact day comparison (ignores time)
findPresetLabel(currentRange, presets)
const findPresetLabel = (currentRange, presets) => {
if (!currentRange || !currentRange[0] || !currentRange[1] || !presets)
return null;
for (const preset of presets) {
const presetStart = preset.value[0];
const presetEnd = preset.value[1];
if (
isSameDay(currentRange[0], presetStart) &&
isSameDay(currentRange[1], presetEnd)
) {
return preset.label;
}
}
return null;
};
Purpose: Match date range to preset label ("Today", "Last 7 days", etc.)
renderSelectedGateways(inFilterDropdown)
const renderSelectedGateways = (inFilterDropdown = false) => {
if (selectedFilters.gateways.length === 0) return null;
const containerStyle = !inFilterDropdown
? { marginTop: "clamp(0.5rem, 2vw, 1rem)" }
: {};
return (
<div style={containerStyle}>
<div>
{selectedFilters.gatewayObjects.map((gatewayObj) => (
<TagItem key={gatewayObj.value}>{gatewayObj.label}</TagItem>
))}
<Text
style={{
display: "inline-block",
marginLeft: "clamp(0.25rem, 0.5vw, 0.375rem)",
verticalAlign: "middle",
}}
>
{selectedFilters.gateways.length > 1
? `${selectedFilters.gateways.length} gateways selected`
: "Gateway selected"}
</Text>
</div>
</div>
);
};
Purpose: Display selected gateways as styled tags + count
getTagPlaceholder()
const getTagPlaceholder = () => {
if (
selectedFilters.gateways.length === 1 &&
selectedFilters.gateways[0] === "all"
) {
return "All Gateways";
}
if (selectedFilters.gateways.length > 0) {
return `${selectedFilters.gateways.length} gateways selected`;
}
return null;
};
Purpose: Show placeholder in Select component when multiple tags
Error Handling & Edge Cases
Edge Case 1: No Data Available for Report
if (!Array.isArray(filteredData) || filteredData.length === 0) {
messageApi.warning("No data available for the report.");
return;
}
Edge Case 2: User Not Admin, No Shared Catalogues
// fetchGateways filters gateways by shared catalogues
// Result: empty catalogueRefsList, no gateways shown in UI
Impact: User sees empty gateway dropdown
Edge Case 3: Date Range With No Data
// querySnapshot returns empty
// fetchedData = []
// UI shows loading completed, empty tables
Edge Case 4: Permission Change During Session
Scenario: Admin user permissions revoked, non-Admin catalogues removed
Handling: Permission retrieved fresh on each fetchGateways call
Edge Case 5: Invalid Date Range in Report Modal
const isFormValid = () => {
return startDateReport && endDateReport && !error && isAnySectionSelected();
};
// Download button disabled if form invalid
Edge Case 6: "All" Gateway Selection Logic
// When selecting individual gateways:
if (updatedSelectedOption.length === catalogueRefsList.length) {
return ["all", ...updatedSelectedOption]; // Auto-add "all"
}
// When deselecting while "all" active:
if (prevSelectedOption.includes("all") && !checked) {
updatedSelectedOption = updatedSelectedOption.filter(
(item) => item !== "all"
);
}
Edge Case 7: Excel Sheet Not Created (Empty Data)
const createSheet = (data, sheetName) => {
if (!data || (Array.isArray(data) && data.length === 0)) {
console.warn(`Sheet "${sheetName}" not created: empty data.`);
return; // Skip sheet creation
}
// ... proceed
};
Impact: Report file created but sheet omitted (no error)
Calculation Functions Integration
All calculation functions are imported from /src/services/utils/gatewayReportCalculation:
import {
calculateDeviceAnalytics,
calculateGatewaysOverview,
calculateLinesGlassesVariants,
calculatePagesAnalytics,
calculateEventsAnalytics,
calculateGeolocationData,
calculateVariantsDetails,
} from "../services/utils/gatewayReportCalculation";
Wrapped with useCallback for performance:
const memoizedCalculateGatewaysOverview = useCallback(
calculateGatewaysOverview,
[]
);
const memoizedCalculateLinesGlassesVariants = useCallback(
calculateLinesGlassesVariants,
[]
);
const memoizedCalculateDeviceAnalytics = useCallback(
calculateDeviceAnalytics,
[]
);
const memoizedCalculatePagesAnalytics = useCallback(
calculatePagesAnalytics,
[]
);
const memoizedCalculateEventsAnalytics = useCallback(
calculateEventsAnalytics,
[]
);
const memoizedCalculateGeolocationData = useCallback(
calculateGeolocationData,
[]
);
API Functions Used
fetchGatewaysFromCollection()
Source: /src/services/api/gatewaysApi.js
Returns: Array of gateway objects from Gateways collection
trackedGetDocs(query, collectionName)
Source: /src/services/firebase/trackedOps.js
Purpose: Firestore query wrapper with consumption tracking
Related Files & Architecture
Main page:
/src/pages/Gateways.js
Components:
/src/components/Gateways/GatewaysOverviewCard.js/src/components/Gateways/DeviceAnalytics.js/src/components/Gateways/PagesAnalytics.js/src/components/Gateways/EventsAnalytics.js/src/components/Gateways/GlassesVariantsAnalytics.js/src/components/Gateways/GeolocationMap.js/src/components/Components/Filters/FilterComponent.js/src/components/Components/Headers/HeaderComponent.js/src/components/Components/Cards/SimpleCard.js
Calculation utilities:
/src/services/utils/gatewayReportCalculation.js
API services:
/src/services/api/gatewaysApi.js
Firebase:
/src/services/firebase/trackedOps.js/src/services/firebase/firebaseTracker.js/src/data/base.js
Data utilities:
/src/data/utils.js(secureGetItem)
Collections:
Gateways(gateway metadata with list_catalogues)Session_Gateway(session data with date and catalogue references)
Performance Optimization Patterns
Pattern 1: Triple-Layer Caching
Problem: Repeated requests for same date/gateway combination
Solution: Persistent cache + in-flight request deduplication
Pattern 2: Buffered Filter Application
Problem: Every filter change triggers fetch (poor UX)
Solution: Buffer filters locally, apply on button click (except dates)
Pattern 3: RBAC Pre-Filtering
Problem: Non-Admin users can't see/access restricted data
Solution: Filter gateways before UI display, filter sessions in query
Pattern 4: useCallback Memoization
Problem: Calculation functions recreated on every render
Solution: Wrap with useCallback, pass to components
Pattern 5: Ref-Based Tracking
Problem: State updates cause fetches to re-run
Solution: Use useRef for cache/snapshot (not re-created on render)