feat(app): implement comprehensive analytics and reporting features

Analytics Page:
- Implemented a new, dynamic analytics page with date filtering.
- Added KPI summary tiles for total, completed, and aborted swaps, average swap time, and station uptime.
- Included three interactive charts: Swap Activity Over Time, Hourly Swap Distribution, and Swap Abort Reasons.
- Added a Slot Utilization Heatmap for at-a-glance diagnostics.
- Styled charts for the dark theme and fixed axis scaling issues.

Station Selection Page:
- Enhanced the station selection page to display live daily swap statistics on each station card.

Backend API:
- Created a new '/api/analytics' endpoint to serve all KPI and chart data.
- Created a new '/api/stations/daily-stats' endpoint.

Fixes & Refactoring:
- Centralized all common header logic into common-header.js to resolve script conflicts.
- Improved dashboard WebSocket handler to correctly process partial data payloads.
main
Kirubakaran 2025-09-15 03:56:47 +05:30
parent f61af015ad
commit 7f0a93a80d
8 changed files with 1155 additions and 884 deletions

View File

@ -5,12 +5,12 @@ import json
import csv
import io
import time
from datetime import datetime
from datetime import datetime, timedelta
from flask import Flask, jsonify, request, Response
from flask_socketio import SocketIO, join_room
from flask_cors import CORS
from dotenv import load_dotenv
from sqlalchemy import desc
from sqlalchemy import desc, func, case
# Import your custom core modules and the new models
from core.mqtt_client import MqttClient
@ -268,6 +268,44 @@ def get_stations():
return jsonify({"error": f"Database query failed: {e}"}), 500
#--- Daily Stats Route ---
@app.route('/api/stations/daily-stats', methods=['GET'])
def get_all_station_stats():
"""
Calculates the swap statistics for today for all stations.
"""
try:
# --- CHANGE THESE TWO LINES ---
today_start = datetime.combine(datetime.now().date(), time.min) # Use local time
today_end = datetime.combine(datetime.now().date(), time.max) # Use local time
# This is an efficient query that groups by station_id and counts events in one go
stats = db.session.query(
MqttLog.station_id,
func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_START', 1))).label('total_starts'),
func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_ENDED', 1))).label('completed'),
func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_ABORTED', 1))).label('aborted')
).filter(
MqttLog.topic_type == 'EVENTS',
MqttLog.timestamp.between(today_start, today_end)
).group_by(MqttLog.station_id).all()
# Convert the list of tuples into a dictionary for easy lookup
stats_dict = {
station_id: {
"total_starts": total_starts,
"completed": completed,
"aborted": aborted
} for station_id, total_starts, completed, aborted in stats
}
return jsonify(stats_dict)
except Exception as e:
print(f"Error fetching daily stats: {e}")
return jsonify({"message": "Could not fetch daily station stats."}), 500
@app.route('/api/logs/recent/<string:station_id>', methods=['GET'])
def get_recent_logs(station_id):
"""
@ -292,6 +330,145 @@ def get_recent_logs(station_id):
return jsonify({"message": "Could not fetch recent logs."}), 500
# A helper dictionary to make abort reason labels more readable
ABORT_REASON_MAP = {
"ABORT_UNKNOWN": "Unknown",
"ABORT_BAT_EXIT_TIMEOUT": "Battery Exit Timeout",
"ABORT_BAT_ENTRY_TIMEOUT": "Battery Entry Timeout",
"ABORT_DOOR_CLOSE_TIMEOUT": "Door Close Timeout",
"ABORT_DOOR_OPEN_TIMEOUT": "Door Open Timeout",
"ABORT_INVALID_PARAM": "Invalid Parameter",
"ABORT_REMOTE_REQUESTED": "Remote Abort",
"ABORT_INVALID_BATTERY": "Invalid Battery"
}
#--- Analytics Route ---
@app.route('/api/analytics', methods=['GET'])
def get_analytics_data():
# 1. Get and validate request parameters (same as before)
station_id = request.args.get('station_id')
start_date_str = request.args.get('start_date')
end_date_str = request.args.get('end_date')
if not all([station_id, start_date_str, end_date_str]):
return jsonify({"message": "Missing required parameters."}), 400
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
start_datetime = datetime.combine(start_date, datetime.min.time())
end_datetime = datetime.combine(end_date, datetime.max.time())
except ValueError:
return jsonify({"message": "Invalid date format. Please use YYYY-MM-DD."}), 400
# 2. Query for EVENT logs (for swap calculations)
try:
event_logs = MqttLog.query.filter(
MqttLog.station_id == station_id,
MqttLog.topic_type == 'EVENTS',
MqttLog.timestamp.between(start_datetime, end_datetime)
).all()
except Exception as e:
return jsonify({"message": f"Could not query event logs: {e}"}), 500
# --- NEW: Query for PERIODIC logs (for uptime calculation) ---
try:
periodic_logs = MqttLog.query.filter(
MqttLog.station_id == station_id,
MqttLog.topic_type == 'PERIODIC',
MqttLog.timestamp.between(start_datetime, end_datetime)
).order_by(MqttLog.timestamp.asc()).all()
except Exception as e:
return jsonify({"message": f"Could not query periodic logs: {e}"}), 500
# 3. Process EVENT logs for swap KPIs and charts
total_swaps, completed_swaps, aborted_swaps = 0, 0, 0
completed_swap_times, daily_completed, daily_aborted, hourly_swaps, abort_reason_counts = [], {}, {}, [0] * 24, {}
slot_utilization_counts = {i: 0 for i in range(1, 10)} # For the heatmap
for log in event_logs:
# (This processing logic is unchanged)
event_type = log.payload.get('eventType')
log_date = log.timestamp.date()
log_hour = log.timestamp.hour
if event_type == 'EVENT_SWAP_START':
total_swaps += 1
hourly_swaps[log_hour] += 1
elif event_type == 'EVENT_SWAP_ENDED':
completed_swaps += 1
daily_completed[log_date] = daily_completed.get(log_date, 0) + 1
swap_time = log.payload.get('eventData', {}).get('swapTime')
if swap_time is not None:
completed_swap_times.append(swap_time)
elif event_type == 'EVENT_SWAP_ABORTED':
aborted_swaps += 1
daily_aborted[log_date] = daily_aborted.get(log_date, 0) + 1
reason = log.payload.get('eventData', {}).get('swapAbortReason', 'ABORT_UNKNOWN')
abort_reason_counts[reason] = abort_reason_counts.get(reason, 0) + 1
elif event_type == 'EVENT_BATTERY_EXIT':
slot_id = log.payload.get('eventData', {}).get('slotId')
if slot_id and slot_id in slot_utilization_counts:
slot_utilization_counts[slot_id] += 1
# --- NEW: 4. Calculate Station Uptime ---
total_period_seconds = (end_datetime - start_datetime).total_seconds()
total_downtime_seconds = 0
MAX_ONLINE_GAP_SECONDS = 30 # Assume offline if no message for over 30 seconds
if not periodic_logs:
total_downtime_seconds = total_period_seconds
else:
# Check gap from start time to first message
first_gap = (periodic_logs[0].timestamp - start_datetime).total_seconds()
if first_gap > MAX_ONLINE_GAP_SECONDS:
total_downtime_seconds += first_gap
# Check gaps between consecutive messages
for i in range(1, len(periodic_logs)):
gap = (periodic_logs[i].timestamp - periodic_logs[i-1].timestamp).total_seconds()
if gap > MAX_ONLINE_GAP_SECONDS:
total_downtime_seconds += gap
# Check gap from last message to end time
last_gap = (end_datetime - periodic_logs[-1].timestamp).total_seconds()
if last_gap > MAX_ONLINE_GAP_SECONDS:
total_downtime_seconds += last_gap
station_uptime = 100 * (1 - (total_downtime_seconds / total_period_seconds))
station_uptime = max(0, min(100, station_uptime)) # Ensure value is between 0 and 100
# 5. Prepare final data structures (KPI section is now updated)
avg_swap_time_seconds = sum(completed_swap_times) / len(completed_swap_times) if completed_swap_times else 0
kpi_data = {
"total_swaps": total_swaps, "completed_swaps": completed_swaps,
"aborted_swaps": aborted_swaps, "avg_swap_time_seconds": avg_swap_time_seconds,
"station_uptime": round(station_uptime, 2) # Add uptime to the KPI object
}
# (The rest of the chart data preparation is unchanged)
date_labels, completed_data, aborted_data = [], [], []
current_date = start_date
while current_date <= end_date:
date_labels.append(current_date.strftime('%b %d'))
completed_data.append(daily_completed.get(current_date, 0))
aborted_data.append(daily_aborted.get(current_date, 0))
current_date += timedelta(days=1)
swap_activity_data = {"labels": date_labels, "completed_data": completed_data, "aborted_data": aborted_data}
hourly_distribution_data = {"labels": [f"{h % 12 if h % 12 != 0 else 12} {'AM' if h < 12 else 'PM'}" for h in range(24)], "swap_data": hourly_swaps}
abort_reasons_data = {"labels": [ABORT_REASON_MAP.get(r, r) for r in abort_reason_counts.keys()], "reason_data": list(abort_reason_counts.values())}
slot_utilization_data = {"counts": [slot_utilization_counts[i] for i in range(1, 10)]} # Return counts as a simple list [_ , _, ...]
# 6. Combine all data and return
return jsonify({
"kpis": kpi_data,
"swap_activity": swap_activity_data,
"hourly_distribution": hourly_distribution_data,
"abort_reasons": abort_reasons_data,
"slot_utilization": slot_utilization_data # <-- ADD THIS NEW KEY
})
# --- CSV Export route (UPDATED) ---
def _format_periodic_row(payload, num_slots=9):
"""

View File

@ -6,19 +6,18 @@
<title>Swap Station Analytics</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="js/common-header.js"></script>
<script src="js/auth-guard.js"></script>
<!-- Inter + Tailwind -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script>
tailwind.config = {
theme: {
@ -35,16 +34,23 @@
:root { color-scheme: dark; }
html, body { height: 100%; }
body { background:#0a0a0a; }
/* soft background glow */
.bg-glow::before,
.bg-glow::after {
content:""; position:fixed; pointer-events:none; z-index:-1; border-radius:9999px; filter: blur(64px);
}
.bg-glow::before { width:34rem; height:34rem; left:-8rem; top:-8rem; background: rgba(16,185,129,.12); }
.bg-glow::after { width:36rem; height:36rem; right:-10rem; bottom:-10rem; background: rgba(56,189,248,.10); }
.glass { background: rgba(30,41,59,.45); border: 1px solid rgba(255,255,255,.10); border-radius: .9rem; backdrop-filter: saturate(150%) blur(12px); }
.tile { background: rgba(2,6,23,.45); border:1px solid rgba(255,255,255,.12); border-radius:.9rem; padding:1rem; }
.btn { font-weight:800; font-size:12px; padding:.5rem .7rem; border-radius:.6rem; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06); transition:.18s; }
.btn:hover { border-color: rgba(16,185,129,.45); background: rgba(16,185,129,.10); }
.cham_chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.cham_chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.cham_chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.cham_chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
@ -62,17 +68,17 @@
.cham_chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.glass { background: rgba(30,41,59,.45); border: 1px solid rgba(255,255,255,.10); border-radius: .9rem; backdrop-filter: saturate(150%) blur(12px); }
.tile { background: rgba(2,6,23,.45); border:1px solid rgba(255,255,255,.12); border-radius:.9rem; padding:1rem; }
.btn { font-weight:800; font-size:12px; padding:.5rem .7rem; border-radius:.6rem; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06); transition:.18s; }
.btn { font-weight:800; font-size:12px; padding:.5rem .7rem; border-radius:.6rem; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06); transition:.18s; }
.btn:hover { border-color: rgba(16,185,129,.45); background: rgba(16,185,129,.10); }
.btn-ghost { border-color: rgba(255,255,255,.10); background: rgba(255,255,255,.05); }
.btn-danger { border-color: rgba(244,63,94,.35); background: rgba(244,63,94,.14); color:#fecaca; }
.btn-danger { border-color: rgba(244,63,94,.35); background: rgba(244,63,94,.14); color: #fecaca; }
.btn-danger:hover { background: rgba(244,63,94,.22); }
.btn-ghost { border-color: rgba(255,255,255,.10); background: rgba(255,255,255,.05); }
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
/* mini bar chart bars */
.bar { width: 10px; border-radius: 6px 6px 0 0; background: linear-gradient(180deg,#22c55e,#0ea5e9); }
.textarea {
width:100%; height:100%; background: linear-gradient(180deg, rgba(2,6,23,.55), rgba(2,6,23,.35));
border:1px dashed rgba(255,255,255,.14); border-radius:.75rem; padding:1rem; color:#d1d5db;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; resize:none; outline:none;
}
</style>
</head>
<body class="min-h-screen text-gray-100 bg-glow">
@ -113,11 +119,6 @@
<span id="device-id" class="font-semibold"></span>
</span>
<!-- <span class="badge border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span>
<span id="device-id"></span>
</span> -->
<span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
@ -135,9 +136,6 @@
</svg>
</button>
<!-- <span id="backup-power-chip" class="cham_chip cham_chip-slate" title="Station power source" style="display: none;">On Backup</span> -->
<!-- <span class="chip chip-amber" title="Running on backup supply">On Backup</span> -->
<div class="hidden sm:block w-px h-5 bg-white/10"></div>
<button id="refreshBtn" class="btn btn-ghost !p-2">
@ -161,7 +159,6 @@
</div>
</div>
<!-- Tabs -->
<div class="border-t border-white/10 bg-black/10">
<nav class="mx-auto max-w-7.5xl px-3 sm:px-4 flex">
<a href="./dashboard.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Main</a>
@ -172,168 +169,81 @@
</header>
<!-- CONTENT -->
<main class="relative z-10 mx-auto max-w-7.5xl px-3 sm:px-4 py-4 flex flex-col gap-4">
<!-- Device + Date Range -->
<section class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span>
<span id="device-id" class="font-bold mono"></span>
</span>
<section class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<button data-range="today" class="date-range-btn btn btn-ghost">Today</button>
<button data-range="7" class="date-range-btn btn btn-ghost">Last 7 Days</button>
<button data-range="30" class="date-range-btn btn btn-ghost">Last 30 Days</button>
</div>
<div class="ml-auto flex items-center gap-2">
<input id="from" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<input id="from" type="text" placeholder="Start Date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<span class="text-gray-500">to</span>
<input id="to" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<input id="to" type="text" placeholder="End Date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<button id="applyRange" class="btn">Apply</button>
</div>
</section>
<!-- Stat Tiles -->
<section class="grid grid-cols-1 md:grid-cols-4 gap-3">
<section class="grid grid-cols-1 md:grid-cols-5 gap-3">
<div class="tile">
<p class="text-xs text-gray-400">Total Swaps (Today)</p>
<p class="text-3xl font-extrabold">142</p>
<p class="text-xs text-gray-400">Total Swaps Initiated</p>
<p id="total-swaps" class="text-3xl font-extrabold text-sky-400">...</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Completed Swaps</p>
<p class="text-3xl font-extrabold text-emerald-400">
<span id="completed-swaps">...</span>
<span id="success-rate" class="text-sm font-bold text-gray-400 ml-1">(...%)</span>
</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Aborted Swaps</p>
<p class="text-3xl font-extrabold text-rose-400">
<span id="aborted-swaps">...</span>
<span id="abort-rate" class="text-sm font-bold text-gray-400 ml-1">(...%)</span>
</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Avg. Swap Time</p>
<p class="text-3xl font-extrabold">2.1 <span class="text-lg font-bold text-gray-300">min</span></p>
<p id="avg-swap-time" class="text-3xl font-extrabold">... <span class="text-lg font-bold text-gray-300">min</span></p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Station Uptime</p>
<p class="text-3xl font-extrabold text-emerald-400">99.8%</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Peak Hours</p>
<p class="text-3xl font-extrabold">57 PM</p>
</div>
<p id="station-uptime" class="text-3xl font-extrabold text-teal-400">... %</p>
</div>
</section>
<!-- Charts -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-[20rem]">
<!-- Weekly Swaps - CSS bars -->
<div class="glass p-4">
<div class="flex items-center justify-between">
<h3 class="font-extrabold">Swaps This Week</h3>
<span class="text-xs text-gray-400">Mon → Sun</span>
</div>
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="mt-4 h-64 rounded-lg border border-white/10 bg-white/5 p-4 flex items-end gap-4 min-h-[20.8rem]">
<!-- Each group: bar + label -->
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:50%"></div><span class="text-xs text-gray-400">Mon</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:74%"></div><span class="text-xs text-gray-400">Tue</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:60%"></div><span class="text-xs text-gray-400">Wed</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:85%"></div><span class="text-xs text-gray-400">Thu</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:92%"></div><span class="text-xs text-gray-400">Fri</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:42%"></div><span class="text-xs text-gray-400">Sat</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:30%"></div><span class="text-xs text-gray-400">Sun</span>
</div>
</div>
<div class="glass p-4 h-96">
<h3 class="font-extrabold">Swap Activity Over Time</h3>
<canvas id="swapActivityChart"></canvas>
</div>
<!-- Battery Health - donut style -->
<div class="glass p-4">
<h3 class="font-extrabold">Battery Health</h3>
<div class="h-64 flex items-center justify-center">
<div class="relative w-52 h-52">
<svg class="w-full h-full" viewBox="0 0 36 36">
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none" stroke="#1f2937" stroke-width="3"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#ef4444" stroke-width="3" stroke-dasharray="20, 100"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#f59e0b" stroke-width="3" stroke-dasharray="30, 100"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#22c55e" stroke-width="3" stroke-dasharray="50, 100"/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-2xl font-extrabold">250</span>
<span class="text-xs text-gray-400">Total Batteries</span>
</div>
</div>
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-xs">
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-emerald-400"></span><span>Good</span></div>
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-amber-400"></span><span>Warning</span></div>
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-rose-400"></span><span>Poor</span></div>
</div>
<div class="glass p-4 h-96">
<h3 class="font-extrabold">Hourly Swap Distribution</h3>
<canvas id="hourlyDistributionChart"></canvas>
</div>
<div class="glass p-4 h-96">
<h3 class="font-extrabold">Swap Abort Reasons</h3>
<canvas id="abortReasonsChart"></canvas>
</div>
<div class="glass p-4 h-96">
<h3 class="font-extrabold mb-4">Slot Utilization Heatmap</h3>
<div id="heatmap-grid" class="grid grid-cols-3 gap-4 h-[calc(100%-2rem)]">
</div>
</div>
<!-- <div class="glass p-4 h-96 flex items-center justify-center">
<p class="text-slate-500">Future Chart Area</p>
</div> -->
</section>
</main>
<script>
// Fill device id from selected station (if stored by station_selection)
document.addEventListener('DOMContentLoaded', () => {
// Replace 'VEC-STN-0128' with the actual ID of the station you want to load
const stationId = 'VEC-STN-0128';
loadStationInfo(stationId);
});
// This function fetches data from your backend and updates the page
async function loadStationInfo(stationId) {
// Find the HTML elements by their IDs
const nameElement = document.getElementById('station-name');
const locationElement = document.getElementById('station-location');
try {
// 1. Fetch data from your backend API endpoint
// You must replace this URL with your actual API endpoint
const response = await fetch(`/api/stations/${stationId}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
// 2. Convert the response into JSON format
// Example JSON: { "name": "VEC-STN-0128", "location": "Sector 62, Noida" }
const stationData = await response.json();
// 3. Update the HTML content with the data from the database
nameElement.textContent = stationData.name;
locationElement.textContent = stationData.location;
} catch (error) {
// If something goes wrong, show an error message
nameElement.textContent = 'Error Loading Station';
locationElement.textContent = 'Could not fetch data.';
console.error('Error fetching station data:', error);
}
}
// Demo "last recv" timestamp
document.querySelector('#last-update-status').textContent =
'Last Recv ' + new Date().toLocaleString();
// Actions
document.querySelector('#logout-btn')?.addEventListener('click', () => {
window.location.href = './index.html';
});
document.querySelector('#refreshBtn')?.addEventListener('click', () => location.reload());
// Date range apply (wire to backend later)
document.querySelector('#applyRange')?.addEventListener('click', () => {
const f = document.querySelector('#from').value;
const t = document.querySelector('#to').value;
if (!f || !t) return alert('Choose a date range first.');
alert(`Apply analytics range:\n${f} → ${t}`);
});
</script>
<script src="./js/analytics.js"></script>
</body>

View File

@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Analytics</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="js/common-header.js"></script>
<script src="js/auth-guard.js"></script>
<!-- Inter + Tailwind -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ["Inter","ui-sans-serif","system-ui"] },
keyframes: { pulseDot: { "0%,100%": { transform:"scale(1)", opacity:1 }, "50%": { transform:"scale(1.2)", opacity:.7 } } },
animation: { pulseDot: "pulseDot 1.2s ease-in-out infinite" }
}
}
}
</script>
<style>
:root { color-scheme: dark; }
html, body { height: 100%; }
body { background:#0a0a0a; }
/* soft background glow */
.bg-glow::before,
.bg-glow::after {
content:""; position:fixed; pointer-events:none; z-index:-1; border-radius:9999px; filter: blur(64px);
}
.bg-glow::before { width:34rem; height:34rem; left:-8rem; top:-8rem; background: rgba(16,185,129,.12); }
.bg-glow::after { width:36rem; height:36rem; right:-10rem; bottom:-10rem; background: rgba(56,189,248,.10); }
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.cham_chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.cham_chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.cham_chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.cham_chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.cham_chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.cham_chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.glass { background: rgba(30,41,59,.45); border: 1px solid rgba(255,255,255,.10); border-radius: .9rem; backdrop-filter: saturate(150%) blur(12px); }
.tile { background: rgba(2,6,23,.45); border:1px solid rgba(255,255,255,.12); border-radius:.9rem; padding:1rem; }
.btn { font-weight:800; font-size:12px; padding:.5rem .7rem; border-radius:.6rem; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06); transition:.18s; }
.btn:hover { border-color: rgba(16,185,129,.45); background: rgba(16,185,129,.10); }
.btn-ghost { border-color: rgba(255,255,255,.10); background: rgba(255,255,255,.05); }
.btn-danger { border-color: rgba(244,63,94,.35); background: rgba(244,63,94,.14); color:#fecaca; }
.btn-danger:hover { background: rgba(244,63,94,.22); }
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
/* mini bar chart bars */
.bar { width: 10px; border-radius: 6px 6px 0 0; background: linear-gradient(180deg,#22c55e,#0ea5e9); }
</style>
</head>
<body class="min-h-screen text-gray-100 bg-glow">
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
<div class="mx-auto max-w-7.5xl px-3 sm:px-4 py-2 grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-2 sm:gap-3">
<a href="./station_selection.html"
class="h-9 w-9 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition"
title="Back">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<div class="flex flex-col leading-tight">
<div id="station-name" class="text-base sm:text-lg font-extrabold tracking-tight">
Loading...
</div>
<div id="station-location" class="text-xs sm:text-sm text-slate-100">
&nbsp; </div>
</div>
</div>
<div class="flex items-center justify-center scale-100">
<img src="./assets/vec_logo.png" alt="VECMOCON"
class="h-7 w-auto opacity-90" onerror="this.style.display='none'"/>
</div>
<div class="flex items-center flex-wrap justify-end gap-1.5 sm:gap-2">
<span class="badge border-white/10 bg-white/5 text-slate-200">
<span>Product ID:</span>
<span id="product-id" class="font-semibold"></span>
</span>
<span class="badge border-white/10 bg-white/5 text-slate-200">
<span>Station ID:</span>
<span id="device-id" class="font-semibold"></span>
</span>
<!-- <span class="badge border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span>
<span id="device-id"></span>
</span> -->
<span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg>
<span id="last-update-status">Waiting...</span>
</span>
<span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend">
<span class="h-2 w-2 rounded-full bg-amber-400"></span> Connecting...
</span>
<button id="station-reset-btn" class="btn btn-danger !p-2" title="Station Reset">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg>
</button>
<!-- <span id="backup-power-chip" class="cham_chip cham_chip-slate" title="Station power source" style="display: none;">On Backup</span> -->
<!-- <span class="chip chip-amber" title="Running on backup supply">On Backup</span> -->
<div class="hidden sm:block w-px h-5 bg-white/10"></div>
<button id="refreshBtn" class="btn btn-ghost !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M21 12a9 9 0 10-3.5 7.1M21 12h-4m4 0l-2.5-2.5"></path>
</svg>
</button>
<button id="downloadBtn" class="btn btn-ghost !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M12 3v12m0 0l4-4m-4 4l-4-4"></path><path d="M5 21h14"></path>
</svg>
</button>
<button id="logout-btn" class="btn btn-danger !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<path d="M16 17l5-5-5-5"></path><path d="M21 12H9"></path>
</svg>
</button>
</div>
</div>
<!-- Tabs -->
<div class="border-t border-white/10 bg-black/10">
<nav class="mx-auto max-w-7.5xl px-3 sm:px-4 flex">
<a href="./dashboard.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Main</a>
<a href="./logs.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Logs</a>
<a href="./analytics.html" class="px-4 py-2 text-sm font-semibold border-b-2 border-emerald-400/70 text-white">Analytics</a>
</nav>
</div>
</header>
<!-- CONTENT -->
<main class="relative z-10 mx-auto max-w-7.5xl px-3 sm:px-4 py-4 flex flex-col gap-4">
<!-- Device + Date Range -->
<section class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span>
<span id="device-id" class="font-bold mono"></span>
</span>
<div class="ml-auto flex items-center gap-2">
<input id="from" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<span class="text-gray-500">to</span>
<input id="to" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<button id="applyRange" class="btn">Apply</button>
</div>
</section>
<!-- Stat Tiles -->
<section class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div class="tile">
<p class="text-xs text-gray-400">Total Swaps (Today)</p>
<p class="text-3xl font-extrabold">142</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Avg. Swap Time</p>
<p class="text-3xl font-extrabold">2.1 <span class="text-lg font-bold text-gray-300">min</span></p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Station Uptime</p>
<p class="text-3xl font-extrabold text-emerald-400">99.8%</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Peak Hours</p>
<p class="text-3xl font-extrabold">57 PM</p>
</div>
</section>
<!-- Charts -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-[20rem]">
<!-- Weekly Swaps - CSS bars -->
<div class="glass p-4">
<div class="flex items-center justify-between">
<h3 class="font-extrabold">Swaps This Week</h3>
<span class="text-xs text-gray-400">Mon → Sun</span>
</div>
<div class="mt-4 h-64 rounded-lg border border-white/10 bg-white/5 p-4 flex items-end gap-4 min-h-[20.8rem]">
<!-- Each group: bar + label -->
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:50%"></div><span class="text-xs text-gray-400">Mon</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:74%"></div><span class="text-xs text-gray-400">Tue</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:60%"></div><span class="text-xs text-gray-400">Wed</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:85%"></div><span class="text-xs text-gray-400">Thu</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:92%"></div><span class="text-xs text-gray-400">Fri</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:42%"></div><span class="text-xs text-gray-400">Sat</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:30%"></div><span class="text-xs text-gray-400">Sun</span>
</div>
</div>
</div>
<!-- Battery Health - donut style -->
<div class="glass p-4">
<h3 class="font-extrabold">Battery Health</h3>
<div class="h-64 flex items-center justify-center">
<div class="relative w-52 h-52">
<svg class="w-full h-full" viewBox="0 0 36 36">
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none" stroke="#1f2937" stroke-width="3"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#ef4444" stroke-width="3" stroke-dasharray="20, 100"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#f59e0b" stroke-width="3" stroke-dasharray="30, 100"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#22c55e" stroke-width="3" stroke-dasharray="50, 100"/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-2xl font-extrabold">250</span>
<span class="text-xs text-gray-400">Total Batteries</span>
</div>
</div>
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-xs">
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-emerald-400"></span><span>Good</span></div>
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-amber-400"></span><span>Warning</span></div>
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-rose-400"></span><span>Poor</span></div>
</div>
</div>
</section>
</main>
<script>
// Fill device id from selected station (if stored by station_selection)
document.addEventListener('DOMContentLoaded', () => {
// Replace 'VEC-STN-0128' with the actual ID of the station you want to load
const stationId = 'VEC-STN-0128';
loadStationInfo(stationId);
});
// This function fetches data from your backend and updates the page
async function loadStationInfo(stationId) {
// Find the HTML elements by their IDs
const nameElement = document.getElementById('station-name');
const locationElement = document.getElementById('station-location');
try {
// 1. Fetch data from your backend API endpoint
// You must replace this URL with your actual API endpoint
const response = await fetch(`/api/stations/${stationId}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
// 2. Convert the response into JSON format
// Example JSON: { "name": "VEC-STN-0128", "location": "Sector 62, Noida" }
const stationData = await response.json();
// 3. Update the HTML content with the data from the database
nameElement.textContent = stationData.name;
locationElement.textContent = stationData.location;
} catch (error) {
// If something goes wrong, show an error message
nameElement.textContent = 'Loading...';
// locationElement.textContent = 'Could not fetch data.';
console.error('Error fetching station data:', error);
}
}
// Demo "last recv" timestamp
document.querySelector('#last-update-status').textContent =
'Last Recv ' + new Date().toLocaleString();
// Actions
document.querySelector('#logout-btn')?.addEventListener('click', () => {
window.location.href = './index.html';
});
document.querySelector('#refreshBtn')?.addEventListener('click', () => location.reload());
// Date range apply (wire to backend later)
document.querySelector('#applyRange')?.addEventListener('click', () => {
const f = document.querySelector('#from').value;
const t = document.querySelector('#to').value;
if (!f || !t) return alert('Choose a date range first.');
alert(`Apply analytics range:\n${f} → ${t}`);
});
</script>
<script src="./js/analytics.js"></script>
</body>
</html>

View File

@ -200,7 +200,7 @@
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg>
<span id="last-update-status">Waiting...</span>
<span id="last-update-status">Waiting for data...</span>
</span>
<span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend">

View File

@ -1,434 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ["Inter","ui-sans-serif","system-ui"] },
keyframes: {
pulseDot: { "0%,100%": { transform:"scale(1)", opacity: 1 }, "50%": { transform:"scale(1.2)", opacity: .7 } }
},
animation: { pulseDot: "pulseDot 1.2s ease-in-out infinite" }
}
}
}
</script>
<style>
:root { color-scheme: dark; }
html, body { height: 100%; margin: 0; }
body { background:#0a0a0a; }
.page { max-width:1400px; }
.glass {
background: rgba(30,41,59,.45);
border: 1px solid rgba(255,255,255,.10);
border-radius: .9rem;
backdrop-filter: saturate(150%) blur(12px);
}
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.1rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.cham_chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.cham_chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.cham_chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.cham_chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.cham_chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.cham_chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.btn { font-weight:700; font-size:10px; padding: 0.15rem 0.5rem; border-radius:.5rem; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06); transition:.18s; }
.btn:hover { border-color: rgba(16,185,129,.45); background: rgba(16,185,129,.10); }
.btn-primary{background-image:linear-gradient(to right,#10b981,#14b8a6,#06b6d4);color:#fff;border-color:transparent}
.btn-primary:hover{filter:brightness(1.05);transform:translateY(-1px)}
.btn-danger { border-color: rgba(244,63,94,.35); background: rgba(244,63,94,.14); color: #fecaca; }
.btn-danger:hover { background: rgba(244,63,94,.22); }
.btn-ghost { border-color: rgba(255,255,255,.10); background: rgba(255,255,255,.05); }
.field{font-size:10px;color:#9ca3af}
.value{font-size:12px;font-weight:600;color:#e5e7eb}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
.chamber-card.paired{border-color:#34d399!important;box-shadow:0 0 0 2px rgba(52,211,153,.45)}
.chamber-card.pending{border-color:#60a5fa!important;box-shadow:0 0 0 2px rgba(96,165,250,.45)}
.door-pill{color:#fff;font-size:10px;font-weight:700;padding:4px;border-radius:6px; width: 100%; text-align: center;}
.door-open{background:#22c55e}.door-close{background:#ef4444}
.bat-id-big{font-size:14px;font-weight:800;border-radius:.5rem;padding:.2rem .4rem;
background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.10)}
</style>
</head>
<body class="min-h-screen text-gray-100 flex flex-col">
<div class="pointer-events-none fixed inset-0">
<div class="absolute -top-24 -left-24 w-[32rem] h-[32rem] rounded-full bg-emerald-500/10 blur-3xl"></div>
<div class="absolute -bottom-24 -right-24 w-[36rem] h-[36rem] rounded-full bg-sky-500/10 blur-3xl"></div>
</div>
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
<div class="mx-auto max-w-7.5xl px-3 sm:px-4 py-2 grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-2 sm:gap-3">
<a href="./station_selection.html"
class="h-9 w-9 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition"
title="Back">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<div class="flex flex-col leading-tight">
<div id="station-name" class="text-base sm:text-lg font-extrabold tracking-tight">
Loading...
</div>
<div id="station-location" class="text-xs sm:text-sm text-slate-100">
&nbsp; </div>
</div>
</div>
<div class="flex items-center justify-center">
<img src="./assets/vec_logo.png" alt="VECMOCON"
class="h-7 w-auto opacity-90" onerror="this.style.display='none'"/>
</div>
<div class="flex items-center flex-wrap justify-end gap-1.5 sm:gap-2">
<span class="badge border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span>
<span id="device-id"></span>
</span>
<span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg>
<span id="last-update-status">Last Recv —</span>
</span>
<span class="cham_chip cham_chip-emerald">
<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online
</span>
<span class="cham_chip cham_chip-amber" title="Running on backup supply">On Backup</span>
<div class="hidden sm:block w-px h-5 bg-white/10"></div>
<button id="refreshBtn" class="btn btn-ghost !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M21 12a9 9 0 10-3.5 7.1M21 12h-4m4 0l-2.5-2.5"></path>
</svg>
</button>
<button id="downloadBtn" class="btn btn-ghost !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M12 3v12m0 0l4-4m-4 4l-4-4"></path><path d="M5 21h14"></path>
</svg>
</button>
<button id="logout-btn" class="btn btn-danger !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<path d="M16 17l5-5-5-5"></path><path d="M21 12H9"></path>
</svg>
</button>
</div>
</div>
<div class="border-t border-white/10 bg-black/10">
<nav class="mx-auto max-w-7.5xl px-3 sm:px-4 flex">
<a href="./dashboard.html" class="px-4 py-2 text-sm font-semibold border-b-2 border-emerald-400/70 text-white">Main</a>
<a href="./logs.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Logs</a>
<a href="./analytics.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Analytics</a>
</nav>
</div>
</header>
<main class="relative z-10 flex-1 w-full px-3 py-3 overflow-y-auto lg:overflow-hidden">
<div class="page mx-auto flex flex-col lg:h-full lg:flex-row gap-3">
<section id="chambersGrid" class="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 lg:grid-rows-3 gap-3"></section>
<aside class="w-full lg:w-96 lg:shrink-0 flex flex-col gap-3 overflow-y-auto">
<section class="glass p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-xm font-bold mb-2">System Diagnostics Code</span>
<span class="text-sm font-bold text-emerald-300">148</span>
</div>
<div class="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
<div class="text-rose-300 text-center">Lock Power Cut</div><div class="text-rose-300 text-center">Main Power Cut</div>
<div class="text-rose-300 text-center">Relayboard CAN</div><div class="text-rose-300 text-center">DB CAN Recv</div>
<div class="text-rose-300 text-center">Smoke Alarm</div><div class="text-rose-300 text-center">Water Alarm</div>
<div class="text-rose-300 text-center">Phase Failure</div><div class="text-rose-300 text-center">Earth Leakage</div>
</div>
<button id="station-reset-btn" class="btn btn-danger w-full mt-3 !py-2">Station Reset</button>
</section>
<section id="swap-panel" class="glass p-4 flex flex-col min-h-[220px]">
<h3 class="text-sm font-bold mb-2">Swap Process</h3>
<div id="swap-pairs-list" class="flex-1 flex flex-wrap gap-2 content-center justify-center">
<p id="swap-idle-text" class="w-full text-sm text-center text-gray-400">
Click a <span class="text-sky-300 font-semibold">empty</span> slot, then an <span class="text-emerald-300 font-semibold">full</span> slot.
</p>
</div>
<div class="mt-3">
<div class="grid grid-cols-2 gap-2">
<button id="start-swap-btn" class="btn btn-primary !py-2" disabled>Start Swaps</button>
<button id="abort-swap-btn" class="btn btn-danger !py-2">Abort Swap</button>
</div>
<button id="clear-swap-btn" class="btn w-full mt-2 !py-2">Clear Selection</button>
</div>
</section>
<section class="glass p-4">
<h3 class="text-sm font-bold mb-2">Audio Command</h3>
<select class="w-full rounded-md bg-white/5 border border-white/10 px-2 py-2 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<option>English</option><option>Hindi</option><option>Tamil</option>
</select>
</section>
<section class="glass p-4 flex-1 flex flex-col min-h-[610px]">
<h3 class="text-sm font-bold mb-2">Instance Log</h3>
<textarea class="flex-1 bg-black/20 border border-white/10 rounded-md p-2 text-xs font-mono resize-none" readonly>[--:--:--] Waiting for data…</textarea>
</section>
</aside>
</div>
</main>
<template id="chamberTemplate">
<div class="chamber-card relative glass rounded-xl p-2 flex flex-col transition border border-white/20">
<div class="text-center absolute left-0 right-0 top-0 -translate-y-1/2">
<span class="bg-slate-800 px-2 text-xs font-extrabold text-gray-200 tracking-wide rounded">
CHAMBER <span class="slotNo">#</span>
</span>
</div>
<div class="mt-2 mb-1.5">
<div class="flex items-center gap-2">
<h4 class="field font-bold text-gray-300 shrink-0">BAT_ID</h4>
<div class="bat-id-big mono truncate flex-1 text-left" title="—"></div>
</div>
</div>
<div class="flex-1 flex gap-2">
<div class="flex-1 space-y-0.5">
<div class="flex items-center justify-between border-b border-white/10 pb-0.5 mb-1">
<h4 class="field font-bold text-gray-300">BATTERY</h4>
<span class="battery-status-pill chip chip-slate"></span>
</div>
<div class="flex justify-between items-baseline"><span class="field">SOC</span><span class="value soc"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Voltage</span><span class="value voltage"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Bat Temp</span><span class="value bat-temp"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Bat Fault</span><span class="value bat-fault text-rose-300"></span></div>
</div>
<div class="flex-1 space-y-0.5 border-l border-white/10 pl-2">
<div class="flex items-center justify-between border-b border-white/10 pb-0.5 mb-1">
<h4 class="field font-bold text-gray-300">CHARGER</h4>
<span class="charger-status-pill chip chip-slate"></span>
</div>
<div class="flex justify-between items-baseline"><span class="field">Current</span><span class="value current"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Slot Temp</span><span class="value slot-temp"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Chg Temp</span><span class="value chg-temp"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Chg Fault</span><span class="value chg-fault text-rose-300"></span></div>
</div>
</div>
<div class="grid grid-cols-4 gap-1.5 mt-2 shrink-0">
<div class="door-pill door-close">CLOSED</div>
<button class="btn">OPEN</button>
<button class="btn">CHG ON</button>
<button class="btn">CHG OFF</button>
</div>
</div>
</template>
<script>
// SCRIPT IS UNCHANGED
const qs = (s) => document.querySelector(s);
(function setDevice() {
const el = document.querySelector('#device-id');
if (!el) return;
try {
const sel = JSON.parse(localStorage.getItem('selected_station') || '{}');
el.textContent = sel?.id || sel?.station_id || '—';
} catch {
el.textContent = '—';
}
})();
document.addEventListener('DOMContentLoaded', () => {
const stationId = 'VEC-STN-0128';
loadStationInfo(stationId);
});
async function loadStationInfo(stationId) {
const nameElement = document.getElementById('station-name');
const locationElement = document.getElementById('station-location');
try {
const mockStationData = { name: "VEC-STN-0128", location: "Sector 62, Noida" };
nameElement.textContent = mockStationData.name;
locationElement.textContent = mockStationData.location;
} catch (error) {
nameElement.textContent = 'Error Loading Station';
locationElement.textContent = 'Could not fetch data.';
console.error('Error fetching station data:', error);
}
}
(function setLastRecv() {
const el = qs('#last-update-status');
if (el) {
// Handle both badge and text styles
const textContent = 'Last Recv ' + new Date().toLocaleTimeString();
if (el.tagName === 'SPAN') {
el.textContent = textContent;
} else {
// Fallback for different structures if needed
el.innerHTML = textContent;
}
}
})();
qs('#refreshBtn')?.addEventListener('click', () => location.reload());
qs('#downloadBtn')?.addEventListener('click', () => {
alert('Hook this to your /api/logs/export endpoint.');
});
qs('#logout-btn')?.addEventListener('click', () => {
window.location.href = './index.html';
});
const grid = document.getElementById('chambersGrid');
const tmpl = document.getElementById('chamberTemplate');
grid.innerHTML = '';
for (let i = 1; i <= 9; i++) {
const node = tmpl.content.cloneNode(true);
const card = node.querySelector('.chamber-card');
card.dataset.chamberId = i;
card.querySelector('.slotNo').textContent = i;
const batIdBig = node.querySelector('.bat-id-big');
const batteryStatus = node.querySelector('.battery-status-pill');
const chargerStatus = node.querySelector('.charger-status-pill');
const socEl = node.querySelector('.soc');
const voltageEl = node.querySelector('.voltage');
const batFaultEl = node.querySelector('.bat-fault');
const chgFaultEl = node.querySelector('.chg-fault');
const batTempEl = node.querySelector('.bat-temp');
const currentEl = node.querySelector('.current');
const slotTempEl = node.querySelector('.slot-temp');
const chgTempEl = node.querySelector('.chg-temp');
const doorPill = node.querySelector('.door-pill');
const present = i % 3 !== 0;
batIdBig.textContent = present ? `TK${510000 + i}X00${200 + i}` : '—';
if (present) {
batteryStatus.textContent = 'Present';
batteryStatus.className = 'battery-status-pill chip chip-emerald';
chargerStatus.textContent = 'Charging';
chargerStatus.className = 'charger-status-pill chip chip-sky';
socEl.textContent = `${Math.max(20, 96 - i*3)}%`;
voltageEl.textContent = `5${i}.2 V`;
batTempEl.textContent = `${25 + i}.0 °C`;
currentEl.textContent = '10.5 A';
slotTempEl.textContent = `${28 + i}.0 °C`;
chgTempEl.textContent = `${35 + i}.0 °C`;
if (i === 5) batFaultEl.textContent = 'OVERHEAT';
} else {
batteryStatus.textContent = 'Absent';
batteryStatus.className = 'battery-status-pill chip chip-rose';
chargerStatus.textContent = 'Idle';
chargerStatus.className = 'charger-status-pill chip chip-slate';
chgFaultEl.textContent = (i === 6) ? 'COMM_FAIL' : '—';
}
card.querySelectorAll('.btn').forEach(b => b.addEventListener('click', e => e.stopPropagation()));
grid.appendChild(node);
}
const swapIdleText = document.getElementById('swap-idle-text');
const swapPairsList = document.getElementById('swap-pairs-list');
const startSwapBtn = document.getElementById('start-swap-btn');
const abortSwapBtn = document.getElementById('abort-swap-btn');
const clearSwapBtn = document.getElementById('clear-swap-btn');
const resetBtn = document.getElementById('station-reset-btn');
let currentPair = [], swapPairs = [];
function updateSwapUI(){
const isBuilding = currentPair.length || swapPairs.length;
if (!swapIdleText) return;
swapIdleText.classList.toggle('hidden', isBuilding);
startSwapBtn.disabled = swapPairs.length === 0;
const pairedOut = swapPairs.map(p=>p[0]), pairedIn = swapPairs.map(p=>p[1]);
document.querySelectorAll('.chamber-card').forEach(card=>{
const n = +card.dataset.chamberId;
card.classList.remove('paired','pending');
if (pairedOut.includes(n) || pairedIn.includes(n)) card.classList.add('paired');
else if (currentPair.includes(n)) card.classList.add('pending');
});
swapPairsList.innerHTML = isBuilding ? '' : swapIdleText.outerHTML;
if (isBuilding){
swapPairs.forEach(p=>{
const e = document.createElement('div');
e.className = 'text-sm font-semibold flex items-center justify-center gap-2 bg-black/20 rounded p-1.5';
e.innerHTML = `<span class="text-emerald-300">${p[0]}</span><span></span><span class="text-sky-300">${p[1]}</span>`;
swapPairsList.appendChild(e);
});
if (currentPair.length){
const e = document.createElement('div');
e.className = 'text-sm font-semibold flex items-center justify-center gap-2 bg-black/20 rounded p-1.5 ring-2 ring-sky-500';
e.innerHTML = `<span class="text-emerald-300 font-bold">${currentPair[0]}</span><span></span><span class="text-gray-400">?</span>`;
swapPairsList.appendChild(e);
}
}
}
function handleChamberClick(num){
if (swapPairs.length >= 4 && currentPair.length === 0) return alert('Maximum of 4 swap pairs reached.');
const usedOut = swapPairs.map(p=>p[0]), usedIn = swapPairs.map(p=>p[1]);
if ((currentPair.length === 0 && usedOut.includes(num)) ||
(currentPair.length === 1 && usedIn.includes(num)) ||
(currentPair.length === 1 && currentPair[0] === num)) return;
currentPair.push(num);
if (currentPair.length === 2){ swapPairs.push([...currentPair]); currentPair = []; }
updateSwapUI();
}
function clearSelection(){ currentPair=[]; swapPairs=[]; updateSwapUI(); }
document.querySelectorAll('.chamber-card').forEach(c =>
c.addEventListener('click', () => handleChamberClick(+c.dataset.chamberId))
);
clearSwapBtn?.addEventListener('click', clearSelection);
startSwapBtn?.addEventListener('click', () => {
if (swapPairs.length){ alert('Executing swaps:\n'+swapPairs.map(p=>`${p[0]} → ${p[1]}`).join('\n')); clearSelection(); }
});
abortSwapBtn?.addEventListener('click', () => alert('Sending Swap Abort command!'));
resetBtn?.addEventListener('click', () => { if (confirm('Reset station?')) alert('Sending Station Reset…'); });
updateSwapUI();
</script>
</body>
</html>

View File

@ -1,38 +1,175 @@
// document.addEventListener('DOMContentLoaded', () => {
// // --- CONFIGURATION ---
// const SOCKET_URL = "http://192.168.1.12:5000";
// const API_BASE = "http://192.168.1.12: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 lastUpdateEl = document.getElementById('last-update-status');
// const connChip = document.getElementById('connection-status-chip');
// const requestLogArea = document.getElementById('request-log-area');
// const eventLogArea = document.getElementById('event-log-area');
// const clearReqBtn = document.getElementById('clear-req');
// const clearEvtBtn = document.getElementById('clear-evt');
// const clearAllBtn = document.getElementById('clear-all');
// const refreshBtn = document.getElementById('refreshBtn');
// const downloadBtn = document.getElementById('downloadBtn');
// const logoutBtn = document.getElementById('logout-btn');
// const resetBtn = document.getElementById('station-reset-btn');
// // --- STATE ---
// let selectedStation = null;
// let socket;
// let statusPollingInterval;
// // --- HELPER FUNCTIONS ---
// const prependLog = (textarea, data) => {
// if (!textarea) return;
// const timestamp = new Date().toLocaleTimeString();
// const formattedJson = JSON.stringify(data, null, 2);
// const newLog = `[${timestamp}]\n${formattedJson}\n\n---------------------------------\n\n`;
// textarea.value = newLog + textarea.value;
// };
// const sendCommand = (command, data = null) => {
// if (!selectedStation || !socket || !socket.connected) {
// console.error(`Cannot send command '${command}', not connected.`);
// return;
// }
// const payload = { station_id: selectedStation.id, command: command, data: data };
// socket.emit('rpc_request', payload);
// };
// const checkStationStatus = async () => {
// if (!selectedStation) return;
// try {
// const response = await fetch(`${API_BASE}/stations`);
// if (!response.ok) return;
// const stations = await response.json();
// const thisStation = stations.find(s => s.id === selectedStation.id);
// if (thisStation) {
// stationNameEl.textContent = thisStation.name;
// stationLocationEl.textContent = thisStation.location;
// if (thisStation.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';
// }
// }
// } catch (error) { console.error("Loading...", error); }
// };
// // --- INITIALIZATION ---
// try {
// selectedStation = JSON.parse(localStorage.getItem('selected_station'));
// if (!selectedStation || !selectedStation.id) {
// throw new Error('No station selected. Please go back to the selection page.');
// }
// deviceIdEl.textContent = selectedStation.id;
// productIdEl.textContent = selectedStation.product_id;
// } catch (e) {
// document.body.innerHTML = `<div class="text-center p-8 text-rose-400">${e.message}<a href="./station_selection.html" class="underline ml-2">Go Back</a></div>`;
// return;
// }
// // --- SOCKET.IO CONNECTION ---
// socket = io(SOCKET_URL);
// socket.on('connect', () => {
// console.log("Connected to WebSocket for logs.");
// socket.emit('join_station_room', { station_id: selectedStation.id });
// });
// socket.on('dashboard_update', (message) => {
// const { stationId, topic, data } = message;
// if (stationId !== selectedStation.id) return;
// lastUpdateEl.textContent = 'Last Recv ' + new Date().toLocaleTimeString();
// if (topic.endsWith('EVENTS')) {
// prependLog(eventLogArea, data);
// } else if (topic.endsWith('REQUEST')) {
// prependLog(requestLogArea, data);
// }
// });
// if(logoutBtn) logoutBtn.addEventListener('click', () => {
// localStorage.clear();
// window.location.href = 'index.html';
// });
// if(refreshBtn) refreshBtn.addEventListener('click', () => location.reload());
// // if(downloadBtn) downloadBtn.addEventListener('click', () => alert("Download functionality can be added here.")); // Placeholder for download modal
// if(resetBtn) resetBtn.addEventListener('click', () => {
// if (confirm('Are you sure you want to reset the station?')) {
// sendCommand('STATION_RESET');
// }
// });
// // --- STARTUP ---
// checkStationStatus();
// statusPollingInterval = setInterval(checkStationStatus, 10000);
// if (typeof lucide !== 'undefined') {
// lucide.createIcons();
// }
// });
document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION ---
const SOCKET_URL = "http://192.168.1.12:5000";
const API_BASE = "http://192.168.1.12:5000/api";
// --- DOM ELEMENT REFERENCES ---
// KPI Tiles
const totalSwapsEl = document.getElementById('total-swaps');
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 avgSwapTimeEl = document.getElementById('avg-swap-time');
// Date Range Elements
const fromDateInput = document.getElementById('from');
const toDateInput = document.getElementById('to');
const applyRangeBtn = document.getElementById('applyRange');
const quickRangeBtns = document.querySelectorAll('.date-range-btn');
// Chart Canvases
const swapActivityCanvas = document.getElementById('swapActivityChart');
const hourlyDistributionCanvas = document.getElementById('hourlyDistributionChart');
const abortReasonsCanvas = document.getElementById('abortReasonsChart');
const stationUptimeEl = document.getElementById('station-uptime');
const heatmapGridEl = document.getElementById('heatmap-grid');
//status elements
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 lastUpdateEl = document.getElementById('last-update-status');
const connChip = document.getElementById('connection-status-chip');
const requestLogArea = document.getElementById('request-log-area');
const eventLogArea = document.getElementById('event-log-area');
const clearReqBtn = document.getElementById('clear-req');
const clearEvtBtn = document.getElementById('clear-evt');
const clearAllBtn = document.getElementById('clear-all');
const refreshBtn = document.getElementById('refreshBtn');
const downloadBtn = document.getElementById('downloadBtn');
const logoutBtn = document.getElementById('logout-btn');
const resetBtn = document.getElementById('station-reset-btn');
const logoutBtn = document.getElementById('logout-btn');
// --- CONSTANTS ---
const chartDefaults = {
color: 'rgba(203, 213, 225, 0.7)', // Light gray for text
borderColor: 'rgba(255, 255, 255, 0.1)', // Subtle grid lines
};
// --- STATE ---
let selectedStation = null;
let socket;
let statusPollingInterval;
let fromDatePicker, toDatePicker;
let swapChartInstance, hourlyChartInstance, abortChartInstance; // To hold chart instances
// --- HELPER FUNCTIONS ---
const prependLog = (textarea, data) => {
if (!textarea) return;
const timestamp = new Date().toLocaleTimeString();
const formattedJson = JSON.stringify(data, null, 2);
const newLog = `[${timestamp}]\n${formattedJson}\n\n---------------------------------\n\n`;
textarea.value = newLog + textarea.value;
};
const sendCommand = (command, data = null) => {
if (!selectedStation || !socket || !socket.connected) {
@ -43,32 +180,275 @@ document.addEventListener('DOMContentLoaded', () => {
socket.emit('rpc_request', payload);
};
const checkStationStatus = async () => {
if (!selectedStation) return;
try {
const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) return;
const stations = await response.json();
const thisStation = stations.find(s => s.id === selectedStation.id);
if (thisStation) {
stationNameEl.textContent = thisStation.name;
stationLocationEl.textContent = thisStation.location;
if (thisStation.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';
/**
* Updates the main KPI tiles with data from the API.
* @param {object} data - The kpis data from the backend.
*/
const updateStatTiles = (data) => {
if (!data) { // Used for loading state or on error
totalSwapsEl.textContent = '...';
completedSwapsEl.textContent = '...';
successRateEl.textContent = '(...%)';
abortedSwapsEl.textContent = '...';
abortRateEl.textContent = '(...%)';
avgSwapTimeEl.innerHTML = '... <span class="text-lg font-bold text-gray-300">min</span>';
stationUptimeEl.textContent = '... %';
return;
}
const total = data.total_swaps ?? 0;
const completed = data.completed_swaps ?? 0;
const aborted = data.aborted_swaps ?? 0;
totalSwapsEl.textContent = total;
completedSwapsEl.textContent = completed;
abortedSwapsEl.textContent = aborted;
const successRate = total > 0 ? ((completed / total) * 100).toFixed(1) : 0;
successRateEl.textContent = `(${successRate}%)`;
const abortRate = total > 0 ? ((aborted / total) * 100).toFixed(1) : 0;
abortRateEl.textContent = `(${abortRate}%)`;
const avgTimeInMinutes = data.avg_swap_time_seconds ? (data.avg_swap_time_seconds / 60).toFixed(1) : '—';
avgSwapTimeEl.innerHTML = `${avgTimeInMinutes} <span class="text-lg font-bold text-gray-300">min</span>`;
stationUptimeEl.textContent = `${data.station_uptime ?? '...'} %`;
};
// --- CHART.JS VISUALIZATION CODE ---
/**
* Renders the "Swap Activity Over Time" bar chart.
* @param {object} data - The chart data from the backend.
*/
const renderSwapActivityChart = (data) => {
if (swapChartInstance) {
swapChartInstance.destroy();
}
// 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
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)',
borderColor: 'rgba(16, 185, 129, 1)',
borderWidth: 1
},
{
label: 'Aborted Swaps',
data: data.aborted_data,
backgroundColor: 'rgba(244, 63, 94, 0.6)',
borderColor: 'rgba(244, 63, 94, 1)',
borderWidth: 1
}
]
},
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 } }
}
}
} catch (error) { console.error("Failed to fetch station status:", error); }
});
};
/**
* Renders the "Hourly Swap Distribution" bar chart.
* @param {object} data - The chart data from the backend.
*/
const renderHourlyDistributionChart = (data) => {
if (hourlyChartInstance) {
hourlyChartInstance.destroy();
}
// 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
hourlyChartInstance = new Chart(hourlyDistributionCanvas, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Total Swaps',
data: data.swap_data,
backgroundColor: 'rgba(56, 189, 248, 0.6)',
borderColor: 'rgba(56, 189, 248, 1)',
borderWidth: 1
}]
},
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 } }
}
}
});
};
/**
* Renders the "Swap Abort Reasons" donut chart.
* @param {object} data - The chart data from the backend.
*/
const renderAbortReasonsChart = (data) => {
if (abortChartInstance) {
abortChartInstance.destroy();
}
abortChartInstance = new Chart(abortReasonsCanvas, {
type: 'doughnut',
data: {
labels: data.labels, // e.g., ['Timeout', 'User Cancelled', ...]
datasets: [{
label: 'Count',
data: data.reason_data, // e.g., [10, 5, ...]
backgroundColor: [
'rgba(244, 63, 94, 0.7)',
'rgba(245, 158, 11, 0.7)',
'rgba(139, 92, 246, 0.7)',
'rgba(56, 189, 248, 0.7)',
],
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top', // Better placement for donuts
labels: { color: chartDefaults.color }
}
}
}
});
};
/**
* Fetches all analytics data from the backend.
* @param {string} startDate - The start date in 'YYYY-MM-DD' format.
* @param {string} endDate - The end date in 'YYYY-MM-DD' format.
*/
const fetchAnalyticsData = async (startDate, endDate) => {
if (!selectedStation) return;
updateStatTiles(null); // Set UI to loading state
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) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to fetch analytics data');
}
const analyticsData = await response.json();
// Update all sections of the page with the new data
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); // Reset UI on error
// Destroy charts on error to show a blank state
if(swapChartInstance) swapChartInstance.destroy();
if(hourlyChartInstance) hourlyChartInstance.destroy();
if(abortChartInstance) abortChartInstance.destroy();
}
};
/**
* Renders the Slot Utilization Heatmap.
* @param {object} data - The slot_utilization data from the backend.
*/
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);
});
};
// --- INITIALIZATION ---
try {
selectedStation = JSON.parse(localStorage.getItem('selected_station'));
if (!selectedStation || !selectedStation.id) {
throw new Error('No station selected. Please go back to the selection page.');
throw new Error('No station selected.');
}
deviceIdEl.textContent = selectedStation.id;
productIdEl.textContent = selectedStation.product_id;
@ -77,43 +457,56 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
// --- SOCKET.IO CONNECTION ---
socket = io(SOCKET_URL);
socket.on('connect', () => {
console.log("Connected to WebSocket for logs.");
socket.emit('join_station_room', { station_id: selectedStation.id });
// Initialize Flatpickr
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" });
// Default to today and fetch initial data
const todayStr = new Date().toISOString().split('T')[0];
fromDatePicker.setDate(todayStr, false);
toDatePicker.setDate(todayStr, false);
fetchAnalyticsData(todayStr, todayStr);
// --- EVENT LISTENERS ---
applyRangeBtn.addEventListener('click', () => {
const startDate = fromDateInput.value;
const endDate = toDateInput.value;
if (!startDate || !endDate) return alert('Please select both a start and end date.');
if (new Date(startDate) > new Date(endDate)) return alert('Start date cannot be after the end date.');
fetchAnalyticsData(startDate, endDate);
});
socket.on('dashboard_update', (message) => {
const { stationId, topic, data } = message;
if (stationId !== selectedStation.id) return;
lastUpdateEl.textContent = 'Last Recv ' + new Date().toLocaleTimeString();
if (topic.endsWith('EVENTS')) {
prependLog(eventLogArea, data);
} else if (topic.endsWith('REQUEST')) {
prependLog(requestLogArea, data);
}
});
// (The rest of your button listeners are unchanged)
if(logoutBtn) logoutBtn.addEventListener('click', () => {
localStorage.clear();
window.location.href = 'index.html';
});
if(refreshBtn) refreshBtn.addEventListener('click', () => location.reload());
// if(downloadBtn) downloadBtn.addEventListener('click', () => alert("Download functionality can be added here.")); // Placeholder for download modal
if(resetBtn) resetBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to reset the station?')) {
sendCommand('STATION_RESET');
}
});
// --- STARTUP ---
checkStationStatus();
statusPollingInterval = setInterval(checkStationStatus, 10000);
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
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));
}
const startDateStr = startDate.toISOString().split('T')[0];
const endDateStr = today.toISOString().split('T')[0];
fromDatePicker.setDate(startDateStr, false);
toDatePicker.setDate(endDateStr, false);
fetchAnalyticsData(startDateStr, endDateStr);
});
});
});

View File

@ -260,196 +260,33 @@ document.addEventListener('DOMContentLoaded', () => {
};
// --- NEW: This function polls the API for the true station status ---
const checkStationStatus = async () => {
if (!selectedStation) return;
try {
const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) return;
const stations = await response.json();
const thisStation = stations.find(s => s.id === selectedStation.id);
// const checkStationStatus = async () => {
// if (!selectedStation) return;
// try {
// const response = await fetch(`${API_BASE}/stations`);
// if (!response.ok) return;
// const stations = await response.json();
// const thisStation = stations.find(s => s.id === selectedStation.id);
if (thisStation && connChip) {
// if (thisStation && connChip) {
stationNameEl.textContent = thisStation.name;
stationLocationEl.textContent = thisStation.location;
// stationNameEl.textContent = thisStation.name;
// stationLocationEl.textContent = thisStation.location;
if (thisStation.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';
lastUpdateEl.textContent = "Waiting for data...";
resetDashboardUI();
}
}
} catch (error) {
console.error("Failed to fetch station status:", error);
}
};
// --- DOWNLOAD MODAL LOGIC ---
const showDownloadModal = () => {
const modalOverlay = document.createElement('div');
modalOverlay.className = "fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50";
modalOverlay.innerHTML = `
<div class="bg-slate-800 border border-slate-700 rounded-lg shadow-xl p-6 w-full max-w-md">
<h3 class="text-lg font-bold text-white mb-4">Export Logs</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Quick Time Ranges</label>
<div class="grid grid-cols-3 gap-2">
<button data-range="1" class="time-range-btn btn btn-ghost !py-1.5">Last Hour</button>
<button data-range="6" class="time-range-btn btn btn-ghost !py-1.5">Last 6 Hours</button>
<button data-range="24" class="time-range-btn btn btn-ghost !py-1.5">Last 24 Hours</button>
<button data-range="today" class="time-range-btn btn btn-ghost !py-1.5">Today</button>
<button data-range="yesterday" class="time-range-btn btn btn-ghost !py-1.5">Yesterday</button>
</div>
</div>
<div>
<label for="log-type" class="block text-sm font-medium text-gray-300">Log Type</label>
<select id="log-type" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
<option value="PERIODIC">Periodic Data</option>
<option value="EVENT">Events & RPC</option>
</select>
</div>
<div>
<label for="start-datetime" class="block text-sm font-medium text-gray-300">Start Date & Time</label>
<input type="text" id="start-datetime" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
</div>
<div>
<label for="end-datetime" class="block text-sm font-medium text-gray-300">End Date & Time</label>
<input type="text" id="end-datetime" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button id="cancel-download" class="btn btn-ghost px-4 py-2">Cancel</button>
<button id="confirm-download" class="btn btn-primary px-4 py-2">Download CSV</button>
</div>
</div>
`;
document.body.appendChild(modalOverlay);
const startInput = document.getElementById('start-datetime');
const endInput = document.getElementById('end-datetime');
// --- NEW: Initialize flatpickr on the inputs ---
const fpConfig = {
enableTime: true,
dateFormat: "Y-m-d\\TH:i", // Format needed by the backend
time_24hr: true
};
const fpStart = flatpickr(startInput, fpConfig);
const fpEnd = flatpickr(endInput, fpConfig);
// --- (The rest of the function is the same) ---
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 3600 * 1000);
fpStart.setDate(oneHourAgo, true);
fpEnd.setDate(now, true);
modalOverlay.querySelectorAll('.time-range-btn').forEach(button => {
button.addEventListener('click', () => {
const range = button.dataset.range;
const now = new Date();
let start = new Date();
if (range === 'today') {
start.setHours(0, 0, 0, 0);
} else if (range === 'yesterday') {
start.setDate(start.getDate() - 1);
start.setHours(0, 0, 0, 0);
now.setDate(now.getDate() - 1);
now.setHours(23, 59, 59, 999);
} else {
start.setHours(now.getHours() - parseInt(range, 10));
}
fpStart.setDate(start, true);
fpEnd.setDate(now, true);
});
});
document.getElementById('cancel-download').onclick = () => document.body.removeChild(modalOverlay);
document.getElementById('confirm-download').onclick = async () => {
const logType = document.getElementById('log-type').value;
const startDateStr = document.getElementById('start-datetime').value;
const endDateStr = document.getElementById('end-datetime').value;
const confirmBtn = document.getElementById('confirm-download');
if (!startDateStr || !endDateStr) {
alert('Please select both a start and end date/time.');
return;
}
// --- Validation Logic ---
const selectedStartDate = new Date(startDateStr);
const selectedEndDate = new Date(endDateStr);
const currentDate = new Date();
if (selectedStartDate > currentDate) {
alert('Error: The start date cannot be in the future.');
return;
}
if (selectedEndDate > currentDate) {
alert('Error: The end date cannot be in the future.');
return;
}
if (selectedStartDate >= selectedEndDate) {
alert('Error: The start date must be earlier than the end date.');
return;
}
// --- Fetch and Download Logic ---
confirmBtn.textContent = 'Fetching...';
confirmBtn.disabled = true;
const downloadUrl = `${API_BASE}/logs/export?station_id=${selectedStation.id}&start_datetime=${startDateStr}&end_datetime=${endDateStr}&log_type=${logType}`;
try {
const response = await fetch(downloadUrl);
if (response.ok) { // Status 200, CSV file received
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
const logType = document.getElementById('log-type').value;
const dateStr = startDateStr.split('T')[0]; // Get just the date part
let filename = `${selectedStation.name || selectedStation.id}_${logType}_${dateStr}.csv`;
const disposition = response.headers.get('Content-Disposition');
if (disposition && disposition.indexOf('attachment') !== -1) {
const filenameMatch = disposition.match(/filename="(.+?)"/);
if (filenameMatch && filenameMatch.length === 2) {
filename = filenameMatch[1];
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
document.body.removeChild(modalOverlay);
} else { // Status 404, no data found
const errorData = await response.json();
alert(`Could not download: ${errorData.message}`);
}
} catch (error) {
alert('An unexpected error occurred. Please check the console.');
console.error('Download error:', error);
} finally {
confirmBtn.textContent = 'Download CSV';
confirmBtn.disabled = false;
}
};
};
// if (thisStation.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';
// lastUpdateEl.textContent = "Waiting for data...";
// resetDashboardUI();
// }
// }
// } catch (error) {
// console.error("Failed to fetch station status:", error);
// }
// };
// --- MAIN LOGIC (Your original code is unchanged) ---
const initializeDashboard = () => {
@ -546,7 +383,6 @@ document.addEventListener('DOMContentLoaded', () => {
window.location.href = './index.html';
});
}
if (downloadBtn) downloadBtn.addEventListener('click', showDownloadModal);
// Audio Command Button (assuming it exists in your HTML)
const sendAudioBtn = document.getElementById('send-audio-btn');
@ -580,36 +416,40 @@ document.addEventListener('DOMContentLoaded', () => {
});
socket.on('dashboard_update', (message) => {
// console.log("DEBUG: Received 'dashboard_update' message:", message);
console.log("DEBUG: Received 'dashboard_update' message:", message);
const { stationId, data } = message;
console.log("Received data payload:", data);
if (stationId !== selectedStation.id) {
console.warn(`Ignoring message for wrong station. Expected ${selectedStation.id}, got ${stationId}`);
return;
}
lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`;
stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
// Show/hide the backup power chip based on the payload data
if (data.backupSupplyStatus === 1) {
backupPowerChip.textContent = 'On Backup';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber';
} else {
backupPowerChip.textContent = 'On Mains Power';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald';
// 1. Check if the 'stationDiagnosticCode' key exists in the data.
if (data.hasOwnProperty('stationDiagnosticCode')) {
const sdc = data.stationDiagnosticCode;
stationDiagCodeEl.textContent = sdc;
updateDiagnosticsUI(sdc);
}
lastUpdateEl.textContent = new Date().toLocaleTimeString();
stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
// --- NEW: Call the function to update the diagnostics grid ---
updateDiagnosticsUI(data.stationDiagnosticCode || 0);
// 2. Check if the 'backupSupplyStatus' key exists.
if (data.hasOwnProperty('backupSupplyStatus')) {
if (data.backupSupplyStatus === 1) {
backupPowerChip.textContent = 'On Backup';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber';
} else {
backupPowerChip.textContent = 'On Mains Power';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald';
}
}
// 3. Only process chamber-level data if it exists.
if (data.slotLevelPayload && Array.isArray(data.slotLevelPayload)) {
data.slotLevelPayload.forEach((slotData, index) => {
const slotId = index + 1;
chamberData[slotId - 1] = slotData; // Keep live data in sync
const card = grid.querySelector(`.chamber-card[data-chamber-id="${slotId}"]`);
if (card) {
updateChamberUI(card, slotData);

View File

@ -256,21 +256,42 @@ document.addEventListener('DOMContentLoaded', () => {
card.className = "group bg-gray-900/60 backdrop-blur-xl rounded-2xl shadow-lg border border-gray-700 transition-transform duration-300 ease-out flex flex-col justify-between hover:-translate-y-1.5 hover:border-emerald-400/60 hover:shadow-[0_0_0_1px_rgba(16,185,129,0.25),0_20px_40px_rgba(0,0,0,0.45)]";
card.innerHTML = `
<div class="main-content p-5 flex-grow cursor-pointer">
<div class="main-content p-5 flex-grow cursor-pointer" data-station-json='${JSON.stringify(station)}'>
<div class="flex justify-between items-start">
<h3 class="text-lg font-bold text-white pr-2">${station.name}</h3>
<div>
<h3 class="text-lg font-bold text-white pr-2">${station.name}</h3>
<p class="text-xs text-slate-400 font-mono"># ${station.product_id || 'N/A'}</p>
</div>
<div class="status-badge flex items-center text-xs font-semibold px-3 py-1 rounded-full ${status.bgColor} ${status.color}">
<i data-lucide="${status.icon}" class="w-4 h-4 mr-1.5"></i>
<span class="status-text">${station.status}</span>
</div>
</div>
<p class="text-sm text-gray-400 mt-1">${station.id}</p>
<p class="text-sm text-gray-400 mt-2 font-mono">${station.id}</p>
</div>
<div class="border-t border-gray-700/50 px-5 py-2 flex justify-end">
<button
class="remove-btn text-xs font-semibold text-red-500 hover:text-red-400 transition"
>
Remove Station
<div class="border-t border-gray-700/50 px-5 pt-3 pb-4">
<div class="grid grid-cols-3 gap-2 text-center">
<div>
<p class="text-xs text-slate-400">Total Starts</p>
<p class="font-bold text-lg text-white stat-total">0</p>
</div>
<div>
<p class="text-xs text-slate-400">Completed</p>
<p class="font-bold text-lg text-emerald-400 stat-completed">0</p>
</div>
<div>
<p class="text-xs text-slate-400">Aborted</p>
<p class="font-bold text-lg text-rose-400 stat-aborted">0</p>
</div>
</div>
</div>
<div class="border-t border-gray-700/50 px-5 py-2 flex justify-between items-center bg-black/20 rounded-b-2xl">
<button class="open-btn text-sm font-bold bg-emerald-500/80 hover:bg-emerald-500 text-white py-1 px-4 rounded-md transition">
Open
</button>
<button class="remove-btn text-gray-400 hover:text-red-500 transition" title="Remove Station">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</button>
</div>
`;
@ -302,6 +323,29 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
//-- NEW: Fetch and apply daily stats to each card ---
const fetchAndApplyStats = async () => {
try {
const response = await fetch(`${API_BASE}/stations/daily-stats`);
if (!response.ok) return; // Fail silently if stats aren't available
const stats = await response.json();
// Loop through the stats object and update each card
for (const stationId in stats) {
const stationCard = stationsGrid.querySelector(`.station-card[data-station-id="${stationId}"]`);
if (stationCard) {
const statData = stats[stationId];
stationCard.querySelector('.stat-total').textContent = statData.total_starts;
stationCard.querySelector('.stat-completed').textContent = statData.completed;
stationCard.querySelector('.stat-aborted').textContent = statData.aborted;
}
}
} catch (error) {
console.error("Could not fetch daily stats:", error);
}
};
// --- MAIN EVENT LISTENER ---
// This single listener handles all clicks on the grid for efficiency.
stationsGrid.addEventListener('click', async (event) => {
@ -356,6 +400,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Otherwise, we can do a more efficient status-only update.
allStations = newStationList;
updateStationStatuses(allStations);
fetchAndApplyStats(); // Fetch and update daily stats
}
} catch (error) {
console.error(error);