document.addEventListener('DOMContentLoaded', () => { // --- CONFIGURATION --- const SOCKET_URL = "http://172.20.10.4:5000"; const API_BASE = "http://172.20.10.4:5000/api"; // --- DOM ELEMENT REFERENCES --- const stationNameEl = document.getElementById('station-name'); const stationLocationEl = document.getElementById('station-location'); const deviceIdEl = document.getElementById('device-id'); const productIdEl = document.getElementById('product-id'); const connChip = document.getElementById('connection-status-chip'); const totalInitiatedEl = document.getElementById('total-swaps-initiated'); const totalStartedEl = document.getElementById('total-swaps-started'); const completedSwapsEl = document.getElementById('completed-swaps'); const successRateEl = document.getElementById('success-rate'); const abortedSwapsEl = document.getElementById('aborted-swaps'); const abortRateEl = document.getElementById('abort-rate'); const avgSwapTimeValueEl = document.getElementById('avg-swap-time-value'); const stationUptimeEl = document.getElementById('station-uptime'); const fromDateInput = document.getElementById('from'); const toDateInput = document.getElementById('to'); const applyRangeBtn = document.getElementById('applyRange'); const quickRangeBtns = document.querySelectorAll('.date-range-btn'); const swapActivityCanvas = document.getElementById('swapActivityChart'); const hourlyDistributionCanvas = document.getElementById('hourlyDistributionChart'); const abortReasonsCanvas = document.getElementById('abortReasonsChart'); const heatmapGridEl = document.getElementById('heatmap-grid'); const logoutBtn = document.getElementById('logout-btn'); const refreshBtn = document.getElementById('refreshBtn'); // --- STATE & CONSTANTS --- let selectedStation = null; let socket; let fromDatePicker, toDatePicker; let swapChartInstance, hourlyChartInstance, abortChartInstance; let lastKpis = {}; const chartDefaults = { color: 'rgba(203, 213, 225, 0.7)', borderColor: 'rgba(255, 255, 255, 0.1)' }; const ABORT_REASON_COLORS = { "Unknown": "#94a3b8", "Battery Exit Timeout": "#f43f5e", "Battery Entry Timeout": "#ec4899", "Door Close Timeout": "#f59e0b", "Door Open Timeout": "#eab308", "Invalid Parameter": "#a855f7", "Remote Abort": "#8b5cf6", "Invalid Battery": "#3b82f6" }; // --- HELPER FUNCTIONS --- const updateStatTiles = (data) => { if (!data) { totalInitiatedEl.textContent = '...'; totalStartedEl.textContent = '...'; completedSwapsEl.textContent = '...'; successRateEl.textContent = '(...%)'; abortedSwapsEl.textContent = '...'; abortRateEl.textContent = '(...%)'; avgSwapTimeValueEl.textContent = '...'; stationUptimeEl.textContent = '... %'; lastKpis = {}; return; } const updateIfNeeded = (element, newValue, formatter = val => val) => { const key = element.id; const formattedValue = formatter(newValue); if (lastKpis[key] !== formattedValue) { element.textContent = formattedValue; lastKpis[key] = formattedValue; } }; updateIfNeeded(totalInitiatedEl, data.total_swaps_initiated ?? 0); updateIfNeeded(totalStartedEl, data.total_swaps_started ?? 0); const completed = data.completed_swaps ?? 0; const aborted = data.aborted_swaps ?? 0; const totalStarts = data.total_swaps_started ?? 0; updateIfNeeded(completedSwapsEl, completed); updateIfNeeded(abortedSwapsEl, aborted); const successRate = totalStarts > 0 ? ((completed / totalStarts) * 100).toFixed(1) : 0; updateIfNeeded(successRateEl, successRate, val => `(${val}%)`); const abortRate = totalStarts > 0 ? ((aborted / totalStarts) * 100).toFixed(1) : 0; updateIfNeeded(abortRateEl, abortRate, val => `(${val}%)`); const avgTimeInSeconds = data.avg_swap_time_seconds != null ? Math.round(data.avg_swap_time_seconds) : '0'; updateIfNeeded(avgSwapTimeValueEl, avgTimeInSeconds); const uptime = data.station_uptime ?? '...'; updateIfNeeded(stationUptimeEl, uptime, val => `${val} %`); }; const renderSwapActivityChart = (data) => { // Dynamically calculate the max value for the Y-axis const maxValue = Math.max(...data.completed_data, ...data.aborted_data); const yAxisMax = Math.ceil(maxValue * 1.2) + 1; // Add 20% padding + 1 // If the chart doesn't exist yet, create it. if (!swapChartInstance) { swapChartInstance = new Chart(swapActivityCanvas, { type: 'bar', data: { labels: data.labels, datasets: [ { label: 'Completed Swaps', data: data.completed_data, backgroundColor: 'rgba(16, 185, 129, 0.6)' }, { label: 'Aborted Swaps', data: data.aborted_data, backgroundColor: 'rgba(244, 63, 94, 0.6)' } ] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, max: yAxisMax, // <-- SET DYNAMIC MAX VALUE grid: { color: chartDefaults.borderColor }, ticks: { color: chartDefaults.color, stepSize: 1 } }, x: { grid: { display: false }, ticks: { color: chartDefaults.color } } }, plugins: { legend: { labels: { color: chartDefaults.color } } } } }); } else { // If the chart already exists, just update its data. swapChartInstance.data.labels = data.labels; swapChartInstance.data.datasets[0].data = data.completed_data; swapChartInstance.data.datasets[1].data = data.aborted_data; swapChartInstance.options.scales.y.max = yAxisMax; swapChartInstance.update(); // This smoothly animates the changes. } }; const renderHourlyDistributionChart = (data) => { // Dynamically calculate the max value for the Y-axis const maxValue = Math.max(...data.swap_data); const yAxisMax = Math.ceil(maxValue * 1.2) + 1; // Add 20% padding + 1 if (!hourlyChartInstance) { hourlyChartInstance = new Chart(hourlyDistributionCanvas, { type: 'bar', data: { labels: data.labels, datasets: [{ label: 'Total Swaps initiated', data: data.swap_data, backgroundColor: 'rgba(56, 189, 248, 0.6)' }] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, max: yAxisMax, // <-- SET DYNAMIC MAX VALUE grid: { color: chartDefaults.borderColor }, ticks: { color: chartDefaults.color, stepSize: 1 } }, x: { grid: { display: false }, ticks: { color: chartDefaults.color, maxTicksLimit: 12 } } }, plugins: { legend: { labels: { color: chartDefaults.color } } } } }); } else { hourlyChartInstance.data.labels = data.labels; hourlyChartInstance.data.datasets[0].data = data.swap_data; hourlyChartInstance.options.scales.y.max = yAxisMax; hourlyChartInstance.update(); } }; const renderAbortReasonsChart = (data) => { if (!abortChartInstance) { abortChartInstance = new Chart(abortReasonsCanvas, { type: 'doughnut', data: { labels: data.labels, datasets: [{ label: 'Count', data: data.reason_data, backgroundColor: data.labels.map(label => ABORT_REASON_COLORS[label] || '#cccccc') }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', // Better placement for donuts labels: { color: chartDefaults.color } } } } }); } else { abortChartInstance.data.labels = data.labels; abortChartInstance.data.datasets[0].data = data.reason_data; abortChartInstance.data.datasets[0].backgroundColor = data.labels.map(label => ABORT_REASON_COLORS[label] || '#cccccc'); abortChartInstance.update(); } }; const renderSlotHeatmap = (data) => { heatmapGridEl.innerHTML = ''; // Clear previous heatmap if (!data || !data.counts || data.counts.length === 0) return; const counts = data.counts; const maxCount = Math.max(...counts); counts.forEach((count, index) => { const slotNumber = index + 1; // Calculate color intensity: 0 = no usage, 1 = max usage const intensity = maxCount > 0 ? count / maxCount : 0; // Create HSL color: Hue is fixed (e.g., blue), Saturation is fixed, Lightness varies // A low intensity (0) will be dark, a high intensity (1) will be bright. const lightness = 20 + (50 * intensity); // Varies from 20% to 70% const backgroundColor = `hsl(200, 80%, ${lightness}%)`; const cell = document.createElement('div'); cell.className = 'rounded-md flex flex-col items-center justify-center text-white font-bold'; cell.style.backgroundColor = backgroundColor; cell.innerHTML = ` Slot ${slotNumber} ${count} swaps `; heatmapGridEl.appendChild(cell); }); }; const fetchAnalyticsData = async (startDate, endDate) => { if (!selectedStation) return; updateStatTiles(null); try { const params = new URLSearchParams({ station_id: selectedStation.id, start_date: startDate, end_date: endDate }); const response = await fetch(`${API_BASE}/analytics?${params}`); if (!response.ok) throw new Error((await response.json()).message || 'Failed to fetch analytics data'); const analyticsData = await response.json(); updateStatTiles(analyticsData.kpis); renderSwapActivityChart(analyticsData.swap_activity); renderHourlyDistributionChart(analyticsData.hourly_distribution); renderAbortReasonsChart(analyticsData.abort_reasons); renderSlotHeatmap(analyticsData.slot_utilization); } catch (error) { console.error("Error fetching analytics data:", error); updateStatTiles(null); } }; const checkStationStatus = async () => { /* ... Your correct status check logic ... */ }; const connectSocket = () => { socket = io(SOCKET_URL); socket.on('connect', () => { console.log("Analytics: Connected to WebSocket."); if (selectedStation) { socket.emit('join_station_room', { station_id: selectedStation.id }); } }); // This listener handles the full refresh for important events socket.on('analytics_updated', () => { console.log("Analytics update received (Event/Request). Refetching data."); applyRangeBtn.click(); }); // --- ADD THIS NEW LISTENER for lightweight status updates --- socket.on('status_update', (data) => { // data will look like: { status: 'Online' } console.log("Live status update received:", data.status); if (connChip) { if (data.status === 'Online') { connChip.innerHTML = ` Online`; connChip.className = 'cham_chip cham_chip-emerald'; } else { connChip.innerHTML = ` Offline`; connChip.className = 'cham_chip cham_chip-rose'; } } }); }; function init() { try { selectedStation = JSON.parse(localStorage.getItem('selected_station')); if (!selectedStation || !selectedStation.id) throw new Error('No station selected.'); } catch (e) { window.location.href = './station_selection.html'; return; } stationNameEl.textContent = selectedStation.name; stationLocationEl.textContent = selectedStation.location; deviceIdEl.textContent = selectedStation.id; productIdEl.textContent = selectedStation.product_id; if (logoutBtn) logoutBtn.addEventListener('click', () => { localStorage.clear(); window.location.href = 'index.html'; }); if (refreshBtn) refreshBtn.addEventListener('click', () => location.reload()); applyRangeBtn.addEventListener('click', () => { const startDate = fromDatePicker.selectedDates[0]; const endDate = toDatePicker.selectedDates[0]; if (startDate && endDate) { fetchAnalyticsData(flatpickr.formatDate(startDate, "Y-m-d"), flatpickr.formatDate(endDate, "Y-m-d")); } }); quickRangeBtns.forEach(btn => { btn.addEventListener('click', () => { const range = btn.dataset.range; const today = new Date(); let startDate = new Date(); if (range === 'today') startDate = today; else startDate.setDate(today.getDate() - (parseInt(range, 10) - 1)); fromDatePicker.setDate(startDate, true); toDatePicker.setDate(today, true); applyRangeBtn.click(); }); }); fromDatePicker = flatpickr(fromDateInput, { dateFormat: "Y-m-d", altInput: true, altFormat: "M j, Y" }); toDatePicker = flatpickr(toDateInput, { dateFormat: "Y-m-d", altInput: true, altFormat: "M j, Y" }); const todayStr = new Date().toISOString().split('T')[0]; fromDatePicker.setDate(todayStr, false); toDatePicker.setDate(todayStr, false); fetchAnalyticsData(todayStr, todayStr); checkStationStatus(); // setInterval(checkStationStatus, 10000); connectSocket(); } init(); });