Files
bytetrack-counter-dashboard/templates/dashboard_lamborghini.html
T

1032 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZenAI APC Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #000000;
--surface: #181818;
--surface2: #202020;
--gold: #FFC000;
--gold-hover: #917300;
--gold-text: #FFCE3E;
--white: #FFFFFF;
--ash: #7D7D7D;
--graphite: #494949;
--steel: #969696;
--shadow: #313131;
--divider: #333333;
--accent: #29ABE2;
--success: #00ff88;
}
body {
font-family: 'Helvetica Neue', 'Arial', sans-serif;
background: var(--bg);
color: var(--white);
min-height: 100vh;
overflow-x: hidden;
}
.container {
max-width: 1600px; margin: 0 auto; padding: 32px 40px;
}
/* Header */
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 0; margin-bottom: 40px;
border-bottom: 1px solid var(--divider);
}
.header-left { display: flex; align-items: center; gap: 16px; }
.logo-icon {
width: 40px; height: 40px;
background: var(--gold); color: #000;
display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 700;
}
.header h1 {
font-size: 16px; font-weight: 400; letter-spacing: 4px;
text-transform: uppercase; color: var(--white);
}
.header-sub { font-size: 9px; color: var(--ash); letter-spacing: 3px; text-transform: uppercase; }
.header-right { display: flex; align-items: center; gap: 24px; }
.header-date {
font-size: 9px; color: var(--ash); letter-spacing: 2px; text-align: right; text-transform: uppercase;
}
.header-date .date-val {
font-size: 14px; color: var(--gold-text); display: block; letter-spacing: 1px;
}
.live-chip {
display: flex; align-items: center; gap: 8px;
font-size: 9px; letter-spacing: 3px; padding: 6px 14px;
text-transform: uppercase; color: var(--white);
border: 1px solid rgba(255,255,255,0.2);
}
.live-chip .dot {
width: 6px; height: 6px; background: var(--success);
}
/* Section title */
.section-title {
font-size: 11px; font-weight: 400; letter-spacing: 4px;
text-transform: uppercase; color: var(--ash);
margin-bottom: 20px; display: inline-block;
}
/* Summary Cards */
.summary-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
margin-bottom: 40px;
background: var(--divider);
}
.s-card {
background: var(--surface);
padding: 28px 24px;
position: relative;
}
.s-card .s-chip {
font-size: 8px; text-transform: uppercase; letter-spacing: 3px; padding: 3px 10px;
font-weight: 600; position: absolute; top: 16px; right: 16px;
color: var(--white); border: 1px solid rgba(255,255,255,0.15);
}
.s-card .s-chip.live-chip-tag {
border-color: var(--success); color: var(--success);
}
.s-card .s-chip.live-chip-tag .ldot {
width: 5px; height: 5px; display: inline-block; background: var(--success); margin-right: 4px;
}
.s-card .s-val {
font-size: 40px; font-weight: 300; color: var(--white);
margin-bottom: 8px; line-height: 1;
}
.s-card .s-label {
font-size: 10px; color: var(--ash); text-transform: uppercase; letter-spacing: 2px;
}
.s-card .s-sub { font-size: 9px; color: var(--graphite); margin-top: 6px; }
.hide-me { display: none; }
/* Video Feed + Recent Activity row */
.live-recent-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
margin-bottom: 40px;
background: var(--divider);
}
.video-panel {
background: var(--surface);
padding: 24px;
display: flex; flex-direction: column;
min-height: 360px;
}
.video-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.video-toggle {
background: none; border: 1px solid rgba(255,255,255,0.3);
padding: 5px 14px; cursor: pointer;
font-size: 9px; letter-spacing: 2px; text-transform: uppercase;
color: var(--ash); transition: border-color 0.3s, color 0.3s;
}
.video-toggle:hover { border-color: var(--white); color: var(--white); }
.video-toggle.active { border-color: var(--success); color: var(--success); }
.video-container {
flex: 1; display: flex; align-items: center; justify-content: center;
background: #000;
overflow: hidden; min-height: 280px;
}
.video-container img { max-width: 100%; max-height: 100%; display: block; }
.video-placeholder {
text-align: center; color: var(--graphite); padding: 40px;
}
.video-placeholder .vp-icon { font-size: 40px; opacity: 0.2; margin-bottom: 12px; }
.video-placeholder .vp-text { font-size: 10px; letter-spacing: 2px; text-transform: uppercase; }
.video-placeholder .vp-hint { font-size: 8px; margin-top: 8px; color: var(--graphite); }
.video-error-overlay {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.8); color: var(--gold);
font-size: 9px; letter-spacing: 2px; text-transform: uppercase;
pointer-events: none; opacity: 0; transition: opacity 0.5s;
}
.video-error-overlay.visible { opacity: 1; }
.video-status {
font-size: 8px; letter-spacing: 2px; text-transform: uppercase;
display: flex; align-items: center; gap: 8px;
}
.video-status.connected { color: var(--success); }
.video-status.waiting { color: var(--ash); }
/* Charts + Stats row */
.charts-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1px;
margin-bottom: 40px;
background: var(--divider);
}
.panel {
background: var(--surface);
padding: 28px;
}
.chart-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 24px; flex-wrap: wrap; gap: 12px;
}
.chart-filters { display: flex; gap: 1px; }
.chart-filters button {
font-size: 9px; letter-spacing: 2px; text-transform: uppercase;
padding: 8px 16px; border: 1px solid rgba(255,255,255,0.1);
background: var(--surface2); color: var(--ash);
cursor: pointer; transition: all 0.3s;
}
.chart-filters button:hover { border-color: var(--white); color: var(--white); }
.chart-filters button.active {
background: var(--gold); color: #000; border-color: var(--gold);
}
.chart-container { position: relative; height: 320px; }
/* Quick Stats */
.quick-stat {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 0; border-bottom: 1px solid var(--divider);
}
.quick-stat:last-child { border-bottom: none; }
.quick-stat .qs-left { display: flex; align-items: center; gap: 14px; }
.quick-stat .qs-icon {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: var(--gold); border: 1px solid var(--divider);
}
.quick-stat .qs-label { font-size: 9px; color: var(--ash); text-transform: uppercase; letter-spacing: 1px; }
.quick-stat .qs-sub { font-size: 8px; color: var(--graphite); }
.quick-stat .qs-val {
font-size: 20px; font-weight: 300; color: var(--white);
}
.recent-section { margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--divider); }
.recent-section .rs-title {
font-size: 9px; text-transform: uppercase; letter-spacing: 4px;
color: var(--ash); margin-bottom: 16px;
}
.recent-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 0; border-bottom: 1px solid var(--divider);
}
.recent-item:last-child { border-bottom: none; }
.recent-item .ri-left { display: flex; align-items: center; gap: 12px; }
.recent-item .ri-badge {
width: 30px; height: 30px;
display: flex; align-items: center; justify-content: center;
font-size: 9px; background: var(--gold); color: #000;
}
.recent-item .ri-date { font-size: 10px; color: var(--white); }
.recent-item .ri-time { font-size: 8px; color: var(--graphite); }
.recent-item .ri-count {
font-size: 14px; font-weight: 300; color: var(--white);
}
.recent-item .ri-dur { font-size: 8px; color: var(--graphite); text-align: right; }
/* Table Panel */
.table-panel {
background: var(--surface);
padding: 28px;
margin-bottom: 40px;
}
.table-toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 24px; flex-wrap: wrap; gap: 12px;
}
.table-toolbar .toolbar-group { display: flex; align-items: center; gap: 8px; }
.table-toolbar input[type="date"] {
font-size: 10px; background: var(--surface2); color: var(--white);
border: 1px solid var(--divider); padding: 8px 14px; outline: none;
text-transform: uppercase; letter-spacing: 1px;
}
.table-toolbar input[type="date"]:focus { border-color: var(--gold); }
.btn {
font-size: 9px; letter-spacing: 3px; text-transform: uppercase;
padding: 10px 20px; cursor: pointer; font-weight: 600; border: none;
transition: all 0.3s;
}
.btn-primary {
background: var(--gold); color: #000;
}
.btn-primary:hover { background: var(--gold-hover); }
.btn-ghost {
background: none; color: var(--ash);
border: 1px solid rgba(255,255,255,0.2);
}
.btn-ghost:hover { border-color: var(--white); color: var(--white); }
.btn-export-btn {
background: var(--white); color: #000;
}
.btn-export-btn:hover { opacity: 0.8; }
table {
width: 100%; border-collapse: collapse;
}
table thead th {
font-size: 8px; text-transform: uppercase; letter-spacing: 3px;
color: var(--ash); text-align: left;
padding-bottom: 14px; border-bottom: 1px solid var(--divider);
}
table tbody td {
padding: 14px 0; font-size: 11px;
border-bottom: 1px solid var(--divider);
color: var(--white);
}
.table-row tr { transition: background 0.2s; cursor: pointer; }
.table-row tr:hover { color: var(--gold); }
.row-date { display: flex; align-items: center; gap: 10px; }
.row-date .rd-icon {
width: 34px; height: 34px;
display: flex; align-items: center; justify-content: center;
font-size: 12px;
background: var(--surface2); color: var(--ash); border: 1px solid var(--divider);
}
.row-date .rd-icon.today {
background: var(--gold); color: #000; border: none;
}
.badge {
display: inline-flex; align-items: center; padding: 4px 12px;
font-size: 8px; letter-spacing: 2px; text-transform: uppercase;
}
.badge-batches { border: 1px solid rgba(255,255,255,0.1); color: var(--ash); }
.badge-green { border: 1px solid var(--success); color: var(--success); }
.badge-gray { border: 1px solid rgba(255,255,255,0.1); color: var(--graphite); }
.link-text { font-size: 9px; letter-spacing: 2px; text-transform: uppercase; cursor: pointer; transition: color 0.2s; color: var(--ash); }
.link-text:hover { color: var(--gold); }
/* Modal */
.modal-overlay {
position: fixed; inset: 0; z-index: 100;
background: rgba(0, 0, 0, 0.85);
display: flex; align-items: center; justify-content: center;
padding: 20px;
}
.modal-overlay.hidden { display: none; }
.modal-content {
background: var(--surface2);
padding: 32px 36px;
width: 100%; max-width: 900px; max-height: 85vh; overflow-y: auto;
transform: scale(0.95); opacity: 0;
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
}
.modal-content.open { transform: scale(1); opacity: 1; }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 24px; padding-bottom: 18px;
border-bottom: 1px solid var(--divider);
}
.modal-header h2 {
font-size: 14px; letter-spacing: 3px; text-transform: uppercase; color: var(--white);
}
.modal-header .close-btn {
background: none; border: 1px solid var(--divider); color: var(--ash);
width: 32px; height: 32px; cursor: pointer;
font-size: 16px; display: flex; align-items: center; justify-content: center;
transition: border-color 0.3s, color 0.3s;
}
.modal-header .close-btn:hover { border-color: var(--gold); color: var(--gold); }
.modal-subtitle { font-size: 9px; color: var(--graphite); letter-spacing: 1px; margin-top: 4px; }
.modal-stats {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
margin-bottom: 24px; background: var(--divider);
}
.modal-stat {
background: var(--surface); padding: 20px; text-align: center;
}
.modal-stat .ms-val {
font-size: 28px; font-weight: 300; color: var(--white);
}
.modal-stat .ms-label { font-size: 8px; color: var(--ash); letter-spacing: 2px; text-transform: uppercase; margin-top: 6px; }
.modal-table-wrap { max-height: 380px; overflow-y: auto; }
/* Footer */
.footer {
margin-top: 40px; text-align: center;
font-size: 8px; letter-spacing: 4px; color: var(--graphite); text-transform: uppercase;
}
/* Scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--graphite); }
/* Responsive */
@media (max-width: 1200px) {
.summary-cards { grid-template-columns: repeat(2, 1fr); }
.charts-row, .live-recent-row { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.container { padding: 20px 16px; }
.summary-cards { grid-template-columns: 1fr; }
.header { flex-direction: column; gap: 16px; }
.header-left { flex-direction: column; text-align: center; }
.header-right { flex-wrap: wrap; justify-content: center; }
.s-card .s-val { font-size: 32px; }
.panel, .video-panel, .table-panel { padding: 20px 16px; }
.modal-content { padding: 20px 16px; max-width: 95vw; }
.modal-stats { grid-template-columns: 1fr; }
.table-toolbar { flex-direction: column; align-items: flex-start; }
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="logo-icon">&#9670;</div>
<div>
<h1>ZenAI APC Module</h1>
<div class="header-sub">Edge Nano counter &mdash; YOLO RockChip</div>
</div>
</div>
<div class="header-right">
<div class="header-date">
<span class="date-val" id="current-date">--</span>
Counting Day
</div>
{% if upload_dashboard_url %}<a class="live-chip" href="{{ upload_dashboard_url }}" style="text-decoration:none;">RECOUNT UPLOAD</a>{% endif %}
<div class="live-chip">
<span class="dot"></span>
<span>{{ site_name }}</span>
</div>
</div>
</div>
<!-- Summary Cards -->
<div class="summary-cards">
<div class="s-card" id="card-live">
<span class="s-chip live-chip-tag"><span class="ldot"></span>LIVE</span>
<div class="s-val" id="live-count">--</div>
<div class="s-label">Current Batch Count</div>
<div class="s-sub">Batch #<span id="live-batch-number">--</span> &middot; <span id="live-last-detection">--</span></div>
</div>
<div class="s-card">
<span class="s-chip">Today</span>
<div class="s-val" id="today-count">--</div>
<div class="s-label">Total Count Today</div>
<div class="s-sub" id="today-batches">-- batches</div>
</div>
<div class="s-card">
<span class="s-chip">Yesterday</span>
<div class="s-val" id="yesterday-count">--</div>
<div class="s-label">Total Count Yesterday</div>
<div class="s-sub" id="yesterday-batches">-- batches</div>
</div>
<div class="s-card">
<span class="s-chip">Average</span>
<div class="s-val" id="avg-count">--</div>
<div class="s-label">Average Per Day</div>
<div class="s-sub" id="total-days">-- days recorded</div>
</div>
</div>
<!-- Best Day (hidden) -->
<div class="hide-me" id="best-day-data">
<span id="best-count">--</span>
<span id="best-date">--</span>
</div>
<!-- Live Video + Recent Activity -->
<div class="live-recent-row">
<div class="video-panel" id="video-panel">
<div class="video-header">
<span class="section-title">Live Camera Feed</span>
<div style="display:flex;align-items:center;gap:10px;">
<span class="video-status waiting" id="video-status">
<span style="width:5px;height:5px;background:var(--ash);display:inline-block;"></span>
waiting...
</span>
<button class="video-toggle" id="videoToggle" onclick="toggleVideo()">STREAM OFF</button>
</div>
</div>
<div class="video-container" id="video-container">
<div class="video-placeholder" id="video-placeholder">
<div class="vp-icon">&#9638;</div>
<div class="vp-text">Live Feed Unavailable</div>
<div class="vp-hint">Enable <code>LIVE_STREAM_ENABLED=true</code> in counter .env</div>
</div>
<img id="live-video-img" alt="Live Feed" style="display:none;" onerror="onVideoError()" onload="onVideoLoad()" />
<div class="video-error-overlay" id="video-error-overlay">Connection lost &mdash; retrying...</div>
</div>
</div>
<div class="panel">
<span class="section-title">Recent Activity</span>
<div id="recent-batches">
<!-- Populated by JS -->
</div>
</div>
</div>
<!-- Charts + Quick Stats -->
<div class="charts-row">
<div class="panel">
<div class="chart-header">
<span class="section-title">Daily Trends</span>
<div class="chart-filters">
<button class="active" onclick="loadChartData(7)" data-days="7">7D</button>
<button onclick="loadChartData(14)" data-days="14">14D</button>
<button onclick="loadChartData(30)" data-days="30">30D</button>
</div>
</div>
<div class="chart-container">
<canvas id="mainChart"></canvas>
</div>
</div>
<div class="panel">
<span class="section-title">Quick Stats</span>
<div style="margin-top:4px;">
<div class="quick-stat">
<div class="qs-left">
<div class="qs-icon"><i class="fas fa-layer-group"></i></div>
<div>
<div class="qs-label">Grand Total</div>
<div class="qs-sub">All time</div>
</div>
</div>
<span class="qs-val" id="grand-total">--</span>
</div>
<div class="quick-stat">
<div class="qs-left">
<div class="qs-icon"><i class="fas fa-cubes"></i></div>
<div>
<div class="qs-label">Total Batches</div>
<div class="qs-sub">All time</div>
</div>
</div>
<span class="qs-val" id="grand-batches">--</span>
</div>
<div class="quick-stat">
<div class="qs-left">
<div class="qs-icon"><i class="fas fa-percentage"></i></div>
<div>
<div class="qs-label">Avg per Batch</div>
<div class="qs-sub">Overall average</div>
</div>
</div>
<span class="qs-val" id="avg-per-batch">--</span>
</div>
</div>
</div>
</div>
<!-- Daily Records Table -->
<div class="table-panel">
<div class="table-toolbar">
<div>
<span class="section-title" style="margin-bottom:0;">Daily Records</span>
<div style="font-size:9px;color:var(--graphite);letter-spacing:1px;">Click on a row to view batch details</div>
</div>
<div class="toolbar-group">
<input type="date" id="date-filter">
<button class="btn btn-primary" onclick="filterByDate()">FILTER</button>
<button class="btn btn-ghost" onclick="resetFilter()">RESET</button>
<button class="btn btn-export-btn" onclick="exportDayDetail()">EXPORT EXCEL</button>
</div>
</div>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th style="padding-left:4px;">Date</th>
<th>Total Count</th>
<th>Batches</th>
<th>Avg/Batch</th>
<th>Status</th>
<th style="padding-right:4px;">Action</th>
</tr>
</thead>
<tbody id="daily-table-body">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
<div class="footer">Edge Nano Counter by ZenAi</div>
</div>
<!-- Modal -->
<div class="modal-overlay hidden" id="detail-modal">
<div class="modal-content" id="modal-content">
<div class="modal-header">
<div>
<h2 id="modal-title">Batch Details</h2>
<div class="modal-subtitle" id="modal-subtitle">--</div>
</div>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<div class="modal-stats">
<div class="modal-stat">
<div class="ms-val" id="modal-total">--</div>
<div class="ms-label">Total Count</div>
</div>
<div class="modal-stat">
<div class="ms-val" id="modal-batches">--</div>
<div class="ms-label">Total Batches</div>
</div>
<div class="modal-stat">
<div class="ms-val" id="modal-avg">--</div>
<div class="ms-label">Avg Duration (min)</div>
</div>
</div>
<div class="modal-table-wrap">
<table>
<thead>
<tr>
<th style="padding-left:4px;">Batch #</th>
<th>Count</th>
<th>Start</th>
<th>End</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="modal-table-body">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
<script>
// --- Data ---
let mainChart = null;
let videoActive = false;
let videoErrorTimer = null;
document.addEventListener('DOMContentLoaded', () => {
loadSummary();
loadCurrentBatch();
loadChartData(7);
loadRecentBatches();
resetFilter();
setInterval(() => {
loadSummary();
loadRecentBatches();
}, 30000);
setInterval(() => {
loadCurrentBatch();
}, 200);
});
async function loadCurrentBatch() {
try {
const res = await fetch('/api/current-batch');
if (!res.ok) throw new Error('No active batch');
const data = await res.json();
document.getElementById('live-count').textContent = data.count.toLocaleString();
document.getElementById('live-batch-number').textContent = data.batch_number || '--';
if (data.last_detection_time) {
const lastDet = new Date(data.last_detection_time);
document.getElementById('live-last-detection').textContent =
lastDet.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
} else {
document.getElementById('live-last-detection').textContent = 'waiting...';
}
} catch (err) {
document.getElementById('live-count').textContent = '0';
document.getElementById('live-batch-number').textContent = '--';
document.getElementById('live-last-detection').textContent = 'no active batch';
}
}
async function loadSummary() {
try {
const res = await fetch('/api/summary');
const data = await res.json();
document.getElementById('current-date').textContent = data.today.date;
document.getElementById('today-count').textContent = data.today.total_count.toLocaleString();
document.getElementById('today-batches').textContent = `${data.today.total_batches} batches`;
document.getElementById('yesterday-count').textContent = data.yesterday.total_count.toLocaleString();
document.getElementById('yesterday-batches').textContent = `${data.yesterday.total_batches} batches`;
document.getElementById('avg-count').textContent = Number(data.average_per_day).toLocaleString();
document.getElementById('total-days').textContent = `${data.all_time.total_days} days recorded`;
document.getElementById('best-count').textContent = data.best_day.count.toLocaleString();
document.getElementById('best-date').textContent = data.best_day.date || '--';
document.getElementById('grand-total').textContent = data.all_time.grand_total.toLocaleString();
document.getElementById('grand-batches').textContent = data.all_time.grand_batches.toLocaleString();
const avgBatch = data.all_time.grand_batches > 0
? (data.all_time.grand_total / data.all_time.grand_batches).toFixed(1)
: 0;
document.getElementById('avg-per-batch').textContent = avgBatch;
} catch (err) {
console.error('Failed to load summary:', err);
}
}
async function loadChartData(days) {
document.querySelectorAll('.chart-filters button').forEach(btn => {
btn.classList.remove('active');
if (parseInt(btn.dataset.days) === days) btn.classList.add('active');
});
try {
const res = await fetch(`/api/daily-data?days=${days}`);
const data = await res.json();
const gridColor = 'rgba(255,255,255,0.04)';
const textColor = '#969696';
const labels = data.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
});
const counts = data.map(d => d.total_count);
const batches = data.map(d => d.total_batches);
if (mainChart) mainChart.destroy();
const ctx = document.getElementById('mainChart').getContext('2d');
mainChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Total Count',
data: counts,
backgroundColor: 'rgba(255, 192, 0, 0.6)',
borderColor: '#FFC000',
borderWidth: 1,
yAxisID: 'y'
},
{
label: 'Batches',
data: batches,
type: 'line',
borderColor: '#29ABE2',
backgroundColor: 'rgba(41, 171, 226, 0.05)',
borderWidth: 2,
pointRadius: 3,
pointBackgroundColor: '#29ABE2',
tension: 0.4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'top',
labels: { usePointStyle: true, padding: 20, font: { size: 10 }, color: textColor }
},
tooltip: {
backgroundColor: '#202020',
padding: 12,
cornerRadius: 0,
titleFont: { size: 11 },
bodyFont: { size: 10 }
}
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 9 }, color: textColor }
},
y: {
type: 'linear',
display: true,
position: 'left',
grid: { color: gridColor },
ticks: { color: textColor, font: { size: 9 } },
title: { display: true, text: 'Count', color: textColor }
},
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { display: false },
ticks: { color: textColor, font: { size: 9 } },
title: { display: true, text: 'Batches', color: textColor }
}
}
}
});
} catch (err) {
console.error('Failed to load chart:', err);
}
}
async function loadRecentBatches() {
try {
const res = await fetch('/api/recent-batches?limit=5');
const batches = await res.json();
const container = document.getElementById('recent-batches');
if (!batches || batches.length === 0) {
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--graphite);font-size:9px;text-transform:uppercase;letter-spacing:1px;">No batches recorded yet</div>';
return;
}
container.innerHTML = batches.map(b => `
<div class="recent-item">
<div class="ri-left">
<div class="ri-badge">#${b.batch_number}</div>
<div>
<div class="ri-date">${b.date}</div>
<div class="ri-time">${formatTime(b.start_time)} - ${formatTime(b.end_time)}</div>
</div>
</div>
<div style="text-align:right;">
<div class="ri-count">${b.count.toLocaleString()}</div>
<div class="ri-dur">${b.duration_minutes}m</div>
</div>
</div>
`).join('');
} catch (err) {
console.error('Failed to load recent batches:', err);
}
}
async function loadAllDates() {
try {
const res = await fetch('/api/available-dates');
const dates = await res.json();
renderDailyTable(dates);
} catch (err) {
console.error('Failed to load dates:', err);
}
}
function resetFilter() {
document.getElementById('date-filter').value = '';
loadAllDates();
}
function renderDailyTable(dates) {
const tbody = document.getElementById('daily-table-body');
const currentDate = document.getElementById('current-date').textContent;
if (dates.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" style="text-align:center;padding:40px;color:var(--graphite);">
<div style="font-size:28px;opacity:0.15;margin-bottom:8px;">&#9633;</div>
<div style="font-size:9px;letter-spacing:3px;text-transform:uppercase;">No Data Available</div>
</td>
</tr>`;
return;
}
tbody.innerHTML = dates.map(d => {
const isToday = d.date === currentDate;
const avgPerBatch = d.total_batches > 0 ? (d.total_count / d.total_batches).toFixed(1) : 0;
return `
<tr onclick="showDayDetail('${d.date}')" style="cursor:pointer;">
<td style="padding-left:4px;">
<div class="row-date">
<div class="rd-icon${isToday ? ' today' : ''}"><i class="fas fa-calendar"></i></div>
<div>
<div style="font-size:10px;">${d.date}</div>
${isToday ? '<div style="font-size:8px;color:var(--gold-text);letter-spacing:1px;text-transform:uppercase;">Today</div>' : ''}
</div>
</div>
</td>
<td><span style="font-size:16px;font-weight:300;">${d.total_count.toLocaleString()}</span></td>
<td><span class="badge badge-batches">${d.total_batches} batch${d.total_batches !== 1 ? 'es' : ''}</span></td>
<td style="color:var(--ash);">${avgPerBatch}</td>
<td>
<span class="badge ${isToday ? 'badge-green' : 'badge-gray'}">
${isToday ? 'Active' : 'Completed'}
</span>
</td>
<td style="padding-right:4px;">
<span class="link-text"><i class="fas fa-eye" style="margin-right:4px;"></i>View</span>
</td>
</tr>`;
}).join('');
}
async function showDayDetail(date) {
try {
const res = await fetch(`/api/day-detail/${date}`);
const data = await res.json();
document.getElementById('modal-title').textContent = `Batch Details - ${date}`;
document.getElementById('modal-subtitle').textContent = `${data.total_batches} batches, ${data.total_count.toLocaleString()} total objects`;
document.getElementById('modal-total').textContent = data.total_count.toLocaleString();
document.getElementById('modal-batches').textContent = data.total_batches;
document.getElementById('modal-avg').textContent = data.avg_duration_minutes;
const tbody = document.getElementById('modal-table-body');
tbody.innerHTML = data.batches.map(b => `
<tr style="border-bottom:1px solid var(--divider);">
<td style="padding:12px 0;padding-left:4px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;background:var(--gold);color:#000;font-size:9px;">
${b.batch_number}
</span>
</td>
<td style="padding:12px 0;">${b.count.toLocaleString()}</td>
<td style="padding:12px 0;color:var(--ash);">${formatTime(b.start_time)}</td>
<td style="padding:12px 0;color:var(--ash);">${formatTime(b.end_time)}</td>
<td style="padding:12px 0;color:var(--ash);">${b.duration_minutes}m</td>
</tr>
`).join('');
const modal = document.getElementById('detail-modal');
const content = document.getElementById('modal-content');
modal.classList.remove('hidden');
requestAnimationFrame(() => {
content.classList.add('open');
});
} catch (err) {
console.error('Failed to load day detail:', err);
}
}
function closeModal() {
const modal = document.getElementById('detail-modal');
const content = document.getElementById('modal-content');
content.classList.remove('open');
setTimeout(() => modal.classList.add('hidden'), 200);
}
function filterByDate() {
const date = document.getElementById('date-filter').value;
if (!date) return;
fetch('/api/available-dates')
.then(r => r.json())
.then(dates => {
const filtered = dates.filter(d => d.date === date);
renderDailyTable(filtered);
});
}
function formatTime(isoString) {
if (!isoString) return '--';
const date = new Date(isoString);
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
// --- Live Video Feed ---
function toggleVideo() {
const btn = document.getElementById('videoToggle');
const img = document.getElementById('live-video-img');
const placeholder = document.getElementById('video-placeholder');
const status = document.getElementById('video-status');
videoActive = !videoActive;
if (videoActive) {
resetFilter();
img.src = '/api/live-video?' + Date.now();
img.style.display = 'block';
if (placeholder) placeholder.style.display = 'none';
btn.textContent = 'STREAM ON';
btn.classList.add('active');
status.innerHTML = '<span style="width:5px;height:5px;background:var(--ash);display:inline-block;"></span> connecting...';
status.className = 'video-status waiting';
} else {
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
btn.textContent = 'STREAM OFF';
btn.classList.remove('active');
status.innerHTML = '<span style="width:5px;height:5px;background:var(--ash);display:inline-block;"></span> stopped';
status.className = 'video-status waiting';
hideVideoError();
}
}
function onVideoLoad() {
const status = document.getElementById('video-status');
status.innerHTML = '<span style="width:5px;height:5px;background:var(--success);display:inline-block;"></span> live';
status.className = 'video-status connected';
hideVideoError();
}
function onVideoError() {
if (!videoActive) { hideVideoError(); return; }
const overlay = document.getElementById('video-error-overlay');
if (overlay) overlay.classList.add('visible');
if (videoErrorTimer) clearTimeout(videoErrorTimer);
videoErrorTimer = setTimeout(() => {
if (videoActive) {
const img = document.getElementById('live-video-img');
img.src = '/api/live-video?' + Date.now();
}
}, 2000);
}
function hideVideoError() {
const overlay = document.getElementById('video-error-overlay');
if (overlay) overlay.classList.remove('visible');
if (videoErrorTimer) { clearTimeout(videoErrorTimer); videoErrorTimer = null; }
}
</script>
<script>
async function exportDayDetail() {
try {
const date = document.getElementById('date-filter')?.value;
const url = date ? `/api/export-day-csv/${date}` : '/api/export-daily-csv';
const res = await fetch(url);
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition');
const filename = disposition
? decodeURIComponent(disposition.replace(/.*filename="?([^";]+)"?.*/, '$1'))
: `export_${date || 'all'}.xlsx`;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
} catch (err) {
alert('Export error: ' + err.message);
}
}
</script>
</body>
</html>