SwapStation_WebApp/frontend/js/analytics.js

317 lines
15 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION ---
const SOCKET_URL = "http://10.10.2.47:5000";
const API_BASE = "http://10.10.2.47: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 = `
<span class="text-xs opacity-70">Slot ${slotNumber}</span>
<span class="text-2xl">${count}</span>
<span class="text-xs opacity-70">swaps</span>
`;
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 = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
connChip.className = 'cham_chip cham_chip-emerald';
} else {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> 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();
});