Skip to main content
Version: 1.0.0

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

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


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)