docker 0.1.2
This commit is contained in:
parent
9e25a38a29
commit
c372146ba8
4
.env
4
.env
@ -1,8 +1,8 @@
|
|||||||
# Server port
|
# Server port
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
WEATHER_API_KEY=openweathermap_api_key
|
WEATHER_API_KEY=27a2e8429bdc47104adb6572ef9f7ad9
|
||||||
ZIP_CODE=00000
|
ZIP_CODE=21224
|
||||||
|
|
||||||
# MariaDB connection
|
# MariaDB connection
|
||||||
DB_CLIENT=mysql
|
DB_CLIENT=mysql
|
||||||
|
@ -8,8 +8,6 @@
|
|||||||
<title>Heat Map</title>
|
<title>Heat Map</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- BINS-style fixed header -->
|
|
||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
<img src="/image/logo.png" class="logo" alt="Amazon Logo">
|
<img src="/image/logo.png" class="logo" alt="Amazon Logo">
|
||||||
<span class="header-title"> | Fuego - Heat Tracker</span>
|
<span class="header-title"> | Fuego - Heat Tracker</span>
|
||||||
@ -19,25 +17,41 @@
|
|||||||
<button class="nav-btn" onclick="location.href='/input-area.html'">Area Reading</button>
|
<button class="nav-btn" onclick="location.href='/input-area.html'">Area Reading</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="page-container">
|
<div class="heatmap-container">
|
||||||
<div class="heatmap-wrapper">
|
|
||||||
|
<!-- Floor selector -->
|
||||||
|
<div id="floor-selector" class="floor-selector">
|
||||||
|
<label><input type="radio" name="floor" value="1" checked> Floor 1</label>
|
||||||
|
<label><input type="radio" name="floor" value="2"> Floor 2</label>
|
||||||
|
<label><input type="radio" name="floor" value="3"> Floor 3</label>
|
||||||
|
<label><input type="radio" name="floor" value="4"> Floor 4</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="heatmap-scroll">
|
||||||
<div id="diagram" class="diagram-container">
|
<div id="diagram" class="diagram-container">
|
||||||
|
|
||||||
<!-- 1) Dock-door row -->
|
<!-- Dock-door row -->
|
||||||
<div id="dock-row" class="dock-row"></div>
|
<div id="dock-row" class="dock-row"></div>
|
||||||
|
|
||||||
<!-- 2) Regions now directly below the dock-door row -->
|
<!-- Regions: A-Mod (east) now on left, AFE center, B-Mod (west) on right -->
|
||||||
<div class="regions-container">
|
<div class="regions-container">
|
||||||
<div id="region-amod" class="region region-amod">A Mod</div>
|
<div id="region-amod" class="region region-amod">
|
||||||
<div id="region-preslam" class="region region-preslam">Outbound Pre-Slam</div>
|
<div class="ar-center">AR Floor<br>A MOD</div>
|
||||||
<div id="region-bmod" class="region region-bmod">B Mod</div>
|
</div>
|
||||||
|
<div id="region-preslam" class="region region-preslam">
|
||||||
|
<div class="ar-center">Outbound Pre-Slam<br>AFE</div>
|
||||||
|
</div>
|
||||||
|
<div id="region-bmod" class="region region-bmod">
|
||||||
|
<div class="ar-center">AR Floor<br>B MOD</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3) Warehouse rectangle -->
|
<!-- Warehouse rectangle -->
|
||||||
<div class="warehouse"></div>
|
<div class="warehouse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
<!-- Tooltip -->
|
||||||
</div>
|
|
||||||
<div id="tooltip" class="tooltip"></div>
|
<div id="tooltip" class="tooltip"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,71 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<title>Fuego – Heat Tracker</title>
|
|
||||||
<!-- your other CSS/links here -->
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* 1) Scrollable container taking full viewport height */
|
|
||||||
#scroll-container {
|
|
||||||
height: 100vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2) WebKit browsers */
|
|
||||||
#scroll-container::-webkit-scrollbar {
|
|
||||||
width: 12px;
|
|
||||||
}
|
|
||||||
#scroll-container::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
}
|
|
||||||
#scroll-container::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #888;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 3px solid #f1f1f1;
|
|
||||||
}
|
|
||||||
#scroll-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 3) Firefox */
|
|
||||||
#scroll-container {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #888 #f1f1f1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="scroll-container">
|
|
||||||
<!-- Your graph/chart -->
|
|
||||||
<div id="graph" style="height: 60vh; padding: 1rem;">
|
|
||||||
<!-- e.g. <canvas id="myChart"></canvas> or your chart library mount point -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Your spreadsheet/table -->
|
|
||||||
<div id="spreadsheet" style="height: 60vh; padding: 1rem;">
|
|
||||||
<!-- e.g. a table or your react/vanilla table component -->
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date/Time</th>
|
|
||||||
<th>Temperature</th>
|
|
||||||
<th>Humidity</th>
|
|
||||||
<th>Heat Index</th>
|
|
||||||
<th>Location</th>
|
|
||||||
<th>Direction</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<!-- rows go here -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- your scripts here -->
|
|
||||||
<script src="/socket.io.js"></script>
|
|
||||||
<script src="main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,36 +1,30 @@
|
|||||||
// public/scripts/heatmap.js
|
// public/scripts/heatmap.js
|
||||||
|
|
||||||
// ==== Configuration ====
|
// ==== CONFIGURATION ====
|
||||||
const dockDoors = [
|
const dockDoors = [
|
||||||
...Array.from({ length: 138 - 124 + 1 }, (_, i) => 124 + i),
|
...Array.from({ length: 138 - 124 + 1 }, (_, i) => 124 + i),
|
||||||
...Array.from({ length: 201 - 142 + 1 }, (_, i) => 142 + i),
|
...Array.from({ length: 201 - 142 + 1 }, (_, i) => 142 + i),
|
||||||
...Array.from({ length: 209 - 202 + 1 }, (_, i) => 202 + i),
|
...Array.from({ length: 209 - 202 + 1 }, (_, i) => 202 + i),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Map region names → element IDs
|
|
||||||
const regionEls = {
|
const regionEls = {
|
||||||
'A-Mod': document.getElementById('region-amod'),
|
'A-Mod': document.getElementById('region-amod'),
|
||||||
'Outbound Pre-Slam': document.getElementById('region-preslam'),
|
'Outbound Pre-Slam': document.getElementById('region-preslam'),
|
||||||
'B-Mod': document.getElementById('region-bmod'),
|
'B-Mod': document.getElementById('region-bmod'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==== State Stores ====
|
const doorData = {}; // dockDoor → [readings]
|
||||||
const doorData = {}; // dockDoor → array of readings
|
const regionData = {}; // regionName → [readings]
|
||||||
const regionData = {}; // regionName → array of readings
|
|
||||||
|
|
||||||
// Initialize data arrays
|
|
||||||
dockDoors.forEach(d => doorData[d] = []);
|
dockDoors.forEach(d => doorData[d] = []);
|
||||||
Object.keys(regionEls).forEach(r => regionData[r] = []);
|
Object.keys(regionEls).forEach(r => regionData[r] = []);
|
||||||
|
|
||||||
// ==== Color Helper ====
|
// ==== HELPERS ====
|
||||||
function getColorFromHI(H) {
|
function getColorFromHI(H) {
|
||||||
const pct = Math.min(Math.max((H - 70) / 30, 0), 1);
|
const pct = Math.min(Math.max((H - 70) / 30, 0), 1);
|
||||||
const r = 255;
|
const r = 255, g = Math.round(255 * (1 - pct));
|
||||||
const g = Math.round(255 * (1 - pct));
|
|
||||||
return `rgba(${r},${g},0,0.7)`;
|
return `rgba(${r},${g},0,0.7)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Stats Helper ====
|
|
||||||
function computeStats(arr) {
|
function computeStats(arr) {
|
||||||
if (!arr.length) return null;
|
if (!arr.length) return null;
|
||||||
let sum = 0, max = arr[0], min = arr[0];
|
let sum = 0, max = arr[0], min = arr[0];
|
||||||
@ -39,13 +33,40 @@ function computeStats(arr) {
|
|||||||
if (r.heatIndex > max.heatIndex) max = r;
|
if (r.heatIndex > max.heatIndex) max = r;
|
||||||
if (r.heatIndex < min.heatIndex) min = r;
|
if (r.heatIndex < min.heatIndex) min = r;
|
||||||
});
|
});
|
||||||
const avg = (sum / arr.length).toFixed(2);
|
return {
|
||||||
const latest = arr[arr.length - 1];
|
latest: arr[arr.length - 1],
|
||||||
return { latest, max, min, avg };
|
max,
|
||||||
|
min,
|
||||||
|
avg: (sum / arr.length).toFixed(2)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Grid Creation ====
|
function showTooltip(html, x, y) {
|
||||||
function createGrid() {
|
const tip = document.getElementById('tooltip');
|
||||||
|
tip.innerHTML = html;
|
||||||
|
tip.style.left = x + 10 + 'px';
|
||||||
|
tip.style.top = y + 10 + 'px';
|
||||||
|
tip.style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideTooltip() {
|
||||||
|
document.getElementById('tooltip').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== LOAD INITIAL DATA ====
|
||||||
|
async function loadInitial() {
|
||||||
|
const all = await fetch('/api/readings').then(r => r.json());
|
||||||
|
all.forEach(r => {
|
||||||
|
if (r.location === 'Inbound' || r.location === 'Outbound') {
|
||||||
|
const d = Number(r.stationDockDoor);
|
||||||
|
if (doorData[d]) doorData[d].push(r);
|
||||||
|
} else if (regionData[r.location]) {
|
||||||
|
regionData[r.location].push(r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== BUILD DOCK-DOOR GRID ====
|
||||||
|
function buildDockRow() {
|
||||||
const row = document.getElementById('dock-row');
|
const row = document.getElementById('dock-row');
|
||||||
dockDoors.forEach(d => {
|
dockDoors.forEach(d => {
|
||||||
const sq = document.createElement('div');
|
const sq = document.createElement('div');
|
||||||
@ -56,144 +77,192 @@ function createGrid() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Initial Load ====
|
// ==== DRAW RING FOR A-Mod / B-Mod ====
|
||||||
async function loadInitial() {
|
function drawRing(regionEl, floor, isBmod) {
|
||||||
const all = await fetch('/api/readings').then(r=>r.json());
|
regionEl.querySelectorAll('.station-square').forEach(sq => sq.remove());
|
||||||
all.forEach(r => {
|
|
||||||
if (r.dockDoor != null) {
|
const W = regionEl.clientWidth;
|
||||||
doorData[r.dockDoor].push(r);
|
const H = regionEl.clientHeight;
|
||||||
} else if (r.region) {
|
const size = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--square-size'));
|
||||||
regionData[r.region].push(r);
|
const gap = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--gap'));
|
||||||
|
const innerW = W - 2*gap;
|
||||||
|
const innerH = H - 2*gap;
|
||||||
|
|
||||||
|
const mk = (x, y, code) => {
|
||||||
|
const sq = document.createElement('div');
|
||||||
|
sq.className = 'station-square';
|
||||||
|
sq.textContent = code;
|
||||||
|
sq.style.left = `${x - size/2}px`;
|
||||||
|
sq.style.top = `${y - size/2}px`;
|
||||||
|
regionEl.appendChild(sq);
|
||||||
|
};
|
||||||
|
|
||||||
|
// SOUTH (dir=3) → top edge
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
const idx = String(i+1).padStart(2,'0');
|
||||||
|
const code = `${floor}3${idx}`;
|
||||||
|
const x = gap + (i + 0.5)*(innerW/26);
|
||||||
|
mk(x, gap/2, code);
|
||||||
}
|
}
|
||||||
});
|
// NORTH (dir=1) → bottom edge
|
||||||
// Color each dock
|
for (let i = 0; i < 26; i++) {
|
||||||
Object.entries(doorData).forEach(([d, arr]) => {
|
const idx = String(i+1).padStart(2,'0');
|
||||||
const stats = computeStats(arr);
|
const code = `${floor}1${idx}`;
|
||||||
if (stats) {
|
const x = gap + (i + 0.5)*(innerW/26);
|
||||||
const el = document.querySelector(`.dock-square[data-door="${d}"]`);
|
mk(x, H - gap/2, code);
|
||||||
el.style.background = getColorFromHI(stats.latest.heatIndex);
|
|
||||||
}
|
}
|
||||||
|
// WEST (dir=4) floors 2–4
|
||||||
|
if (floor > 1) {
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const idx = String(i+1).padStart(2,'0');
|
||||||
|
const code = `${floor}4${idx}`;
|
||||||
|
const y = gap + (i + 0.5)*((innerH - size)/8) + size/2;
|
||||||
|
mk(gap/2, y, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// EAST (dir=2) only B-Mod floors 2–4
|
||||||
|
if (isBmod && floor > 1) {
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const idx = String(i+1).padStart(2,'0');
|
||||||
|
const code = `${floor}2${idx}`;
|
||||||
|
const y = gap + (i + 0.5)*((innerH - size)/8) + size/2;
|
||||||
|
mk(W - gap/2, y, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== SIMPLIFIED AFE DRAW ====
|
||||||
|
function drawAFE(regionEl, floor) {
|
||||||
|
// remove old
|
||||||
|
regionEl.querySelectorAll('.station-square').forEach(sq => sq.remove());
|
||||||
|
|
||||||
|
if (floor === 1) {
|
||||||
|
for (let i = 1; i <= 21; i++) {
|
||||||
|
const sq = document.createElement('div');
|
||||||
|
sq.className = 'station-square';
|
||||||
|
sq.textContent = String(i);
|
||||||
|
regionEl.appendChild(sq);
|
||||||
|
}
|
||||||
|
} else if (floor === 2) {
|
||||||
|
for (let i = 30; i <= 71; i++) {
|
||||||
|
const sq = document.createElement('div');
|
||||||
|
sq.className = 'station-square';
|
||||||
|
sq.textContent = String(i);
|
||||||
|
regionEl.appendChild(sq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// floors 3 & 4: no stations
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== COLORING ====
|
||||||
|
function colorCurrentFloor(floor) {
|
||||||
|
// dock-doors
|
||||||
|
dockDoors.forEach(d => {
|
||||||
|
const arr = doorData[d];
|
||||||
|
if (!arr.length) return;
|
||||||
|
const latest = arr[arr.length - 1];
|
||||||
|
if (!latest.stationDockDoor.startsWith(String(floor))) return;
|
||||||
|
const sq = document.querySelector(`.dock-square[data-door="${d}"]`);
|
||||||
|
if (sq) sq.style.background = getColorFromHI(latest.heatIndex);
|
||||||
|
});
|
||||||
|
// regions and AFE
|
||||||
|
Object.entries(regionEls).forEach(([regionName, el]) => {
|
||||||
|
regionData[regionName]
|
||||||
|
.filter(r => r.stationDockDoor.startsWith(String(floor)))
|
||||||
|
.forEach(r => {
|
||||||
|
const sq = Array.from(el.getElementsByClassName('station-square'))
|
||||||
|
.find(s => s.textContent === r.stationDockDoor);
|
||||||
|
if (sq) sq.style.background = getColorFromHI(r.heatIndex);
|
||||||
});
|
});
|
||||||
// Color each region
|
|
||||||
Object.entries(regionEls).forEach(([r, el]) => {
|
|
||||||
const stats = computeStats(regionData[r]);
|
|
||||||
if (stats) el.style.background = getColorFromHI(stats.latest.heatIndex);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== SSE Subscriptions ====
|
// ==== FLOOR SWITCHING ====
|
||||||
|
function setupFloorSelector(cb) {
|
||||||
|
document.getElementsByName('floor').forEach(rb => {
|
||||||
|
rb.addEventListener('change', () => cb(rb.value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderFloor(floor) {
|
||||||
|
drawRing(regionEls['A-Mod'], +floor, false);
|
||||||
|
drawAFE (regionEls['Outbound Pre-Slam'],+floor);
|
||||||
|
drawRing(regionEls['B-Mod'], +floor, true);
|
||||||
|
colorCurrentFloor(floor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== REAL-TIME SSE ====
|
||||||
function subscribeRealtime() {
|
function subscribeRealtime() {
|
||||||
const es = new EventSource('/api/stream');
|
const es = new EventSource('/api/stream');
|
||||||
|
|
||||||
es.addEventListener('new-reading', e => {
|
es.addEventListener('new-reading', e => {
|
||||||
const r = JSON.parse(e.data);
|
const r = JSON.parse(e.data);
|
||||||
if (doorData[r.dockDoor]) {
|
const sq = document.querySelector(`.dock-square[data-door="${r.stationDockDoor}"]`);
|
||||||
doorData[r.dockDoor].push(r);
|
if (sq) sq.style.background = getColorFromHI(r.heatIndex);
|
||||||
const el = document.querySelector(`.dock-square[data-door="${r.dockDoor}"]`);
|
|
||||||
el.style.background = getColorFromHI(r.heatIndex);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
es.addEventListener('new-area-reading', e => {
|
es.addEventListener('new-area-reading', e => {
|
||||||
const r = JSON.parse(e.data);
|
const r = JSON.parse(e.data);
|
||||||
if (regionData[r.region]) {
|
const el = regionEls['Outbound Pre-Slam'];
|
||||||
regionData[r.region].push(r);
|
Array.from(el.getElementsByClassName('station-square')).forEach(sq => {
|
||||||
const el = regionEls[r.region];
|
if (sq.textContent === r.stationDockDoor) {
|
||||||
el.style.background = getColorFromHI(r.heatIndex);
|
sq.style.background = getColorFromHI(r.heatIndex);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Tooltip Setup ====
|
// ==== TOOLTIP SETUP ====
|
||||||
function setupTooltips() {
|
function setupTooltips() {
|
||||||
const tooltip = document.getElementById('tooltip');
|
const diagram = document.getElementById('diagram');
|
||||||
|
diagram.addEventListener('mousemove', e => {
|
||||||
// Common hover handlers
|
const tgt = e.target;
|
||||||
function showTip(e, infoHtml) {
|
// dock-door
|
||||||
tooltip.innerHTML = infoHtml;
|
if (tgt.classList.contains('dock-square')) {
|
||||||
tooltip.style.display = 'block';
|
const d = Number(tgt.dataset.door);
|
||||||
}
|
|
||||||
function moveTip(e) {
|
|
||||||
tooltip.style.top = `${e.clientY + 10}px`;
|
|
||||||
tooltip.style.left = `${e.clientX + 10}px`;
|
|
||||||
}
|
|
||||||
function hideTip() {
|
|
||||||
tooltip.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dock squares
|
|
||||||
document.querySelectorAll('.dock-square').forEach(sq => {
|
|
||||||
sq.addEventListener('mouseenter', e => {
|
|
||||||
const d = Number(sq.dataset.door);
|
|
||||||
const stats = computeStats(doorData[d]);
|
const stats = computeStats(doorData[d]);
|
||||||
if (!stats) return;
|
if (!stats) return hideTooltip();
|
||||||
const lt = new Date(stats.latest.timestamp).toLocaleString();
|
const { latest, max, min, avg } = stats;
|
||||||
const ht = new Date(stats.max.timestamp).toLocaleString();
|
|
||||||
const lt2= new Date(stats.min.timestamp).toLocaleString();
|
|
||||||
const html = `
|
const html = `
|
||||||
<strong>Door ${d}</strong><br>
|
<strong>Door ${d}</strong><br>
|
||||||
Latest: HI ${stats.latest.heatIndex}, T ${stats.latest.temperature}°F, H ${stats.latest.humidity}%<br>
|
Latest: HI ${latest.heatIndex}, T ${latest.temperature}°F, H ${latest.humidity}%<br>
|
||||||
Time: ${lt}<br>
|
Time: ${new Date(latest.timestamp).toLocaleString()}<br>
|
||||||
Max: HI ${stats.max.heatIndex} at ${ht}<br>
|
Max HI: ${max.heatIndex} @ ${new Date(max.timestamp).toLocaleString()}<br>
|
||||||
Min: HI ${stats.min.heatIndex} at ${lt2}<br>
|
Min HI: ${min.heatIndex} @ ${new Date(min.timestamp).toLocaleString()}<br>
|
||||||
Avg HI: ${stats.avg}
|
Avg HI: ${avg}
|
||||||
`;
|
`;
|
||||||
showTip(e, html);
|
return showTooltip(html, e.pageX, e.pageY);
|
||||||
});
|
}
|
||||||
sq.addEventListener('mousemove', moveTip);
|
// station-square
|
||||||
sq.addEventListener('mouseleave', hideTip);
|
if (tgt.classList.contains('station-square')) {
|
||||||
});
|
let stats = null, regionName = null;
|
||||||
|
for (const [name, arr] of Object.entries(regionData)) {
|
||||||
// Regions
|
if (arr.some(r => r.stationDockDoor === tgt.textContent)) {
|
||||||
Object.entries(regionEls).forEach(([r, el]) => {
|
regionName = name;
|
||||||
el.addEventListener('mouseenter', e => {
|
stats = computeStats(arr.filter(r => r.stationDockDoor === tgt.textContent));
|
||||||
const stats = computeStats(regionData[r]);
|
break;
|
||||||
if (!stats) return;
|
}
|
||||||
const lr = stats.latest;
|
}
|
||||||
|
if (!stats) return hideTooltip();
|
||||||
|
const { latest, max, min, avg } = stats;
|
||||||
const html = `
|
const html = `
|
||||||
<strong>${r}</strong><br>
|
<strong>${regionName} ${tgt.textContent}</strong><br>
|
||||||
Latest Station: ${lr.stationCode}<br>
|
Latest: HI ${latest.heatIndex}, T ${latest.temperature}°F, H ${latest.humidity}%<br>
|
||||||
HI ${lr.heatIndex}, T ${lr.temperature}°F, H ${lr.humidity}%<br>
|
Time: ${new Date(latest.timestamp).toLocaleString()}<br>
|
||||||
Time: ${new Date(lr.timestamp).toLocaleString()}<br>
|
Max HI: ${max.heatIndex} @ ${new Date(max.timestamp).toLocaleString()}<br>
|
||||||
Max HI: ${stats.max.heatIndex}, Min HI: ${stats.min.heatIndex}<br>
|
Min HI: ${min.heatIndex} @ ${new Date(min.timestamp).toLocaleString()}<br>
|
||||||
Avg HI: ${stats.avg}
|
Avg HI: ${avg}
|
||||||
`;
|
`;
|
||||||
showTip(e, html);
|
return showTooltip(html, e.pageX, e.pageY);
|
||||||
});
|
}
|
||||||
el.addEventListener('mousemove', moveTip);
|
hideTooltip();
|
||||||
el.addEventListener('mouseleave', hideTip);
|
|
||||||
});
|
});
|
||||||
|
diagram.addEventListener('mouseleave', hideTooltip);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Zoom & Pan via CSS Vars ====
|
// ==== INITIALIZATION ====
|
||||||
function setupZoom() {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const root = document.documentElement.style;
|
buildDockRow();
|
||||||
let scale = 1;
|
await loadInitial();
|
||||||
const init = {
|
setupFloorSelector(val => renderFloor(val));
|
||||||
size: 60,
|
renderFloor(1);
|
||||||
gap: 8,
|
|
||||||
vgap: 24,
|
|
||||||
wh: 200
|
|
||||||
};
|
|
||||||
const wrapper = document.querySelector('.heatmap-wrapper');
|
|
||||||
wrapper.addEventListener('wheel', e => {
|
|
||||||
if (e.ctrlKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
scale += e.deltaY * -0.001;
|
|
||||||
scale = Math.min(Math.max(scale, 0.5), 3);
|
|
||||||
root.setProperty('--square-size', `${init.size * scale}px`);
|
|
||||||
root.setProperty('--gap', `${init.gap * scale}px`);
|
|
||||||
root.setProperty('--v-gap', `${init.vgap * scale}px`);
|
|
||||||
root.setProperty('--warehouse-height',`${init.wh * scale}px`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==== Init ====
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
createGrid();
|
|
||||||
loadInitial();
|
|
||||||
subscribeRealtime();
|
subscribeRealtime();
|
||||||
setupTooltips();
|
setupTooltips();
|
||||||
setupZoom();
|
|
||||||
});
|
});
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
// average helper
|
// public/scripts/trends.js
|
||||||
|
|
||||||
|
// Helper to compute average
|
||||||
function average(arr) {
|
function average(arr) {
|
||||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// interval mappings
|
// Period definitions
|
||||||
const periodKeys = ['hourly','daily','weekly','monthly','yearly'];
|
const periodKeys = ['hourly','daily','weekly','monthly','yearly'];
|
||||||
const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly'];
|
const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly'];
|
||||||
|
|
||||||
// bucket definitions
|
|
||||||
const periodConfig = {
|
const periodConfig = {
|
||||||
hourly: { keyFn: r => r.timestamp.slice(0,13), labelFn: k => k.replace('T',' ') },
|
hourly: { keyFn: r => r.timestamp.slice(0,13), labelFn: k => k.replace('T',' ') },
|
||||||
daily: { keyFn: r => r.timestamp.slice(0,10), labelFn: k => k },
|
daily: { keyFn: r => r.timestamp.slice(0,10), labelFn: k => k },
|
||||||
@ -20,41 +20,34 @@ const periodConfig = {
|
|||||||
yearly: { keyFn: r => r.timestamp.slice(0,4), labelFn: k => k }
|
yearly: { keyFn: r => r.timestamp.slice(0,4), labelFn: k => k }
|
||||||
};
|
};
|
||||||
|
|
||||||
// global Chart.js instance
|
|
||||||
let trendChart, dataTable;
|
let trendChart, dataTable;
|
||||||
|
|
||||||
// fetch readings from server
|
// Fetch all readings from server
|
||||||
async function fetchReadings() {
|
async function fetchReadings() {
|
||||||
const res = await fetch('/api/readings');
|
const res = await fetch('/api/readings');
|
||||||
return res.ok ? res.json() : [];
|
return res.ok ? res.json() : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// determine direction based on dock door #
|
// Render the DataTable under the chart
|
||||||
function getDirection(dock) {
|
|
||||||
return ( (dock>=124 && dock<=138) || (dock>=202 && dock<=209) )
|
|
||||||
? 'Inbound'
|
|
||||||
: 'Outbound';
|
|
||||||
}
|
|
||||||
|
|
||||||
// render DataTable
|
|
||||||
function renderTable(allReadings) {
|
function renderTable(allReadings) {
|
||||||
const tbody = $('#trendTable tbody').empty();
|
const tbody = $('#trendTable tbody').empty();
|
||||||
allReadings.forEach(r => {
|
allReadings.forEach(r => {
|
||||||
const ts = new Date(r.timestamp).toLocaleString('en-US', { timeZone:'Europe/London' });
|
const ts = new Date(r.timestamp)
|
||||||
const dir = getDirection(r.dockDoor);
|
.toLocaleString('en-US',{timeZone:'America/New_York'});
|
||||||
|
const loc = r.stationDockDoor; // dock door or station code
|
||||||
|
const dir = r.location; // Inbound, Outbound, A-Mod, AFE-1...
|
||||||
tbody.append(`
|
tbody.append(`
|
||||||
<tr>
|
<tr>
|
||||||
<td>${ts}</td>
|
<td>${ts}</td>
|
||||||
<td>${r.temperature.toFixed(1)}</td>
|
<td>${r.temperature.toFixed(1)}</td>
|
||||||
<td>${r.humidity.toFixed(1)}</td>
|
<td>${r.humidity.toFixed(1)}</td>
|
||||||
<td>${r.heatIndex.toFixed(1)}</td>
|
<td>${r.heatIndex.toFixed(1)}</td>
|
||||||
<td>${r.dockDoor}</td>
|
<td>${loc}</td>
|
||||||
<td>${dir}</td>
|
<td>${dir}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// initialize or redraw DataTable
|
|
||||||
if ($.fn.DataTable.isDataTable('#trendTable')) {
|
if ($.fn.DataTable.isDataTable('#trendTable')) {
|
||||||
dataTable.clear().rows.add($('#trendTable tbody tr')).draw();
|
dataTable.clear().rows.add($('#trendTable tbody tr')).draw();
|
||||||
} else {
|
} else {
|
||||||
@ -69,21 +62,21 @@ function renderTable(allReadings) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// draw or update chart
|
// Build or update the Chart.js line chart
|
||||||
async function drawTrend() {
|
async function drawTrend() {
|
||||||
const all = await fetchReadings();
|
const all = await fetchReadings();
|
||||||
const slider = document.getElementById('periodSlider');
|
const slider = document.getElementById('periodSlider');
|
||||||
const periodKey = periodKeys[slider.value];
|
const key = periodKeys[slider.value];
|
||||||
const cfg = periodConfig[periodKey];
|
const cfg = periodConfig[key];
|
||||||
|
|
||||||
// group & stats
|
// Group readings by time bucket
|
||||||
const groups = {};
|
const groups = {};
|
||||||
all.forEach(r => {
|
all.forEach(r => {
|
||||||
const key = cfg.keyFn(r);
|
const k = cfg.keyFn(r);
|
||||||
groups[key] = groups[key] || { temps:[], hums:[], his:[] };
|
groups[k] = groups[k] || { temps:[], hums:[], his:[] };
|
||||||
groups[key].temps.push(r.temperature);
|
groups[k].temps.push(r.temperature);
|
||||||
groups[key].hums.push(r.humidity);
|
groups[k].hums.push(r.humidity);
|
||||||
groups[key].his.push(r.heatIndex);
|
groups[k].his.push(r.heatIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
const labels = Object.keys(groups).sort();
|
const labels = Object.keys(groups).sort();
|
||||||
@ -96,9 +89,9 @@ async function drawTrend() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// selected toggles
|
// Create datasets from checked toggles
|
||||||
const checks = Array.from(document.querySelectorAll('#metricToggles input:checked'));
|
const checked = Array.from(document.querySelectorAll('#metricToggles input:checked'));
|
||||||
const datasets = checks.map(chk => {
|
const datasets = checked.map(chk => {
|
||||||
const m = chk.dataset.metric, s = chk.dataset.stat;
|
const m = chk.dataset.metric, s = chk.dataset.stat;
|
||||||
return {
|
return {
|
||||||
label: `${m.toUpperCase()} ${s.toUpperCase()}`,
|
label: `${m.toUpperCase()} ${s.toUpperCase()}`,
|
||||||
@ -116,10 +109,7 @@ async function drawTrend() {
|
|||||||
} else {
|
} else {
|
||||||
trendChart = new Chart(ctx, {
|
trendChart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: { labels: labels.map(cfg.labelFn), datasets },
|
||||||
labels: labels.map(cfg.labelFn),
|
|
||||||
datasets
|
|
||||||
},
|
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@ -139,23 +129,20 @@ async function drawTrend() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// always update table below
|
// Always update the table below
|
||||||
renderTable(all);
|
renderTable(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
// wire up controls
|
// Wire up controls and initial draw
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// initial draw
|
|
||||||
drawTrend();
|
drawTrend();
|
||||||
|
|
||||||
// slider
|
|
||||||
document.getElementById('periodSlider')
|
document.getElementById('periodSlider')
|
||||||
.addEventListener('input', e => {
|
.addEventListener('input', e => {
|
||||||
document.getElementById('periodLabel').textContent = periodLabels[e.target.value];
|
document.getElementById('periodLabel').textContent = periodLabels[e.target.value];
|
||||||
drawTrend();
|
drawTrend();
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggles
|
|
||||||
document.querySelectorAll('#metricToggles input')
|
document.querySelectorAll('#metricToggles input')
|
||||||
.forEach(chk => chk.addEventListener('change', drawTrend));
|
.forEach(chk => chk.addEventListener('change', drawTrend));
|
||||||
});
|
});
|
@ -2,38 +2,25 @@
|
|||||||
:root {
|
:root {
|
||||||
--square-size: 60px;
|
--square-size: 60px;
|
||||||
--gap: 8px;
|
--gap: 8px;
|
||||||
--v-gap: 24px;
|
--v-gap: 40px;
|
||||||
--warehouse-height: 200px;
|
--warehouse-height: 200px;
|
||||||
|
--region-height: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= GLOBAL & LAYOUT ================= */
|
/* ================= GLOBAL & RESET ================= */
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #f4f4f4;
|
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
}
|
background: #f4f4f4;
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.page-container {
|
|
||||||
margin-top: 70px; /* under fixed header */
|
|
||||||
width: 95%;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
}
|
||||||
flex-direction: column;
|
*, *::before, *::after {
|
||||||
min-height: calc(100vh - 70px);
|
box-sizing: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= BINS HEADER & NAV BUTTONS ================= */
|
/* ================= HEADER & NAV ================= */
|
||||||
.main-header {
|
.main-header {
|
||||||
background-color: #232F3E;
|
background-color: #232F3E;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -67,8 +54,27 @@ h1 {
|
|||||||
background-color: #e48f00;
|
background-color: #e48f00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= PAGE & CONTAINERS ================= */
|
||||||
|
.page-container,
|
||||||
|
.heatmap-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 70px; /* height of header */
|
||||||
|
}
|
||||||
|
.page-container {
|
||||||
|
width: 95%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.heatmap-container {
|
||||||
|
height: calc(100vh - 70px);
|
||||||
|
}
|
||||||
|
|
||||||
/* ================= FORM STYLES ================= */
|
/* ================= FORM STYLES ================= */
|
||||||
.form-input {
|
.form-input, .form-textarea {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: 1rem auto;
|
margin: 1rem auto;
|
||||||
@ -102,56 +108,46 @@ legend {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= TRENDS PAGE ================= */
|
/* ================= FLOOR SELECTOR ================= */
|
||||||
/* Controls */
|
.floor-selector {
|
||||||
#trend-controls {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 1rem;
|
padding: 0.5rem 0;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
/* Chart */
|
.floor-selector label {
|
||||||
.chart-container {
|
margin: 0 1rem;
|
||||||
flex: 1 1 auto;
|
font-weight: bold;
|
||||||
width: 100%;
|
cursor: pointer;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
.chart-container canvas {
|
.floor-selector input {
|
||||||
width: 100% !important;
|
margin-right: 0.25rem;
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
/* Table */
|
|
||||||
.table-container {
|
|
||||||
margin-top: 2rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
table.dataTable {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= HEATMAP STYLES ================= */
|
/* ================= HEATMAP SCROLL & ZOOM ================= */
|
||||||
/* Scroll & pan wrapper */
|
.heatmap-scroll {
|
||||||
.heatmap-wrapper {
|
flex: 1;
|
||||||
width: 100%;
|
overflow: auto;
|
||||||
height: 100%;
|
position: relative;
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
/* Entire diagram */
|
|
||||||
|
/* ================= DIAGRAM LAYOUT ================= */
|
||||||
.diagram-container {
|
.diagram-container {
|
||||||
|
width: calc((var(--square-size) * 83) + (var(--gap) * 82));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--v-gap);
|
gap: var(--v-gap);
|
||||||
|
padding: var(--v-gap);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dock-door row */
|
/* Dock-door row */
|
||||||
.dock-row {
|
.dock-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
/* Each dock square */
|
|
||||||
.dock-square {
|
.dock-square {
|
||||||
width: var(--square-size);
|
width: var(--square-size);
|
||||||
height: var(--square-size);
|
height: var(--square-size);
|
||||||
@ -162,40 +158,82 @@ table.dataTable {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #333;
|
color: #333;
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
/* Regions below dock row */
|
|
||||||
|
/* ================= REGIONS ================= */
|
||||||
.regions-container {
|
.regions-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
}
|
}
|
||||||
.region {
|
.region {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 120px;
|
height: var(--region-height);
|
||||||
border: 2px solid #666;
|
border: 2px solid #666;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: flex;
|
overflow: hidden;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
.region-amod { background: #eef; }
|
.region-amod { background: #eef; }
|
||||||
.region-preslam { background: #efe; }
|
.region-preslam { background: #efe; }
|
||||||
.region-bmod { background: #fee; }
|
.region-bmod { background: #fee; }
|
||||||
/* Warehouse rectangle */
|
|
||||||
|
/* AFE override: line up station squares horizontally */
|
||||||
|
#region-preslam {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
padding: var(--gap) 0;
|
||||||
|
}
|
||||||
|
#region-preslam .station-square {
|
||||||
|
position: static;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: calc(var(--gap)/2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= CENTER LABEL ================= */
|
||||||
|
.region .ar-center {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%,-50%);
|
||||||
|
white-space: pre-line;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: rgba(0,0,0,0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= STATION SQUARES ================= */
|
||||||
|
.region .station-square {
|
||||||
|
position: absolute;
|
||||||
|
width: var(--square-size);
|
||||||
|
height: var(--square-size);
|
||||||
|
line-height: var(--square-size);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #111;
|
||||||
|
background: #ddd;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 1px 1px 3px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= WAREHOUSE RECTANGLE ================= */
|
||||||
.warehouse {
|
.warehouse {
|
||||||
width: calc(
|
width: calc((var(--square-size) * 83) + (var(--gap) * 82));
|
||||||
(var(--square-size) * 83) + /* adjust door count if needed */
|
|
||||||
(var(--gap) * 82)
|
|
||||||
);
|
|
||||||
height: var(--warehouse-height);
|
height: var(--warehouse-height);
|
||||||
background: #ccc;
|
background: #ccc;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
/* Hover tooltip */
|
|
||||||
|
/* ================= TOOLTIP ================= */
|
||||||
.tooltip {
|
.tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@ -211,5 +249,5 @@ table.dataTable {
|
|||||||
|
|
||||||
/* ================= UTILITY ================= */
|
/* ================= UTILITY ================= */
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
291
server.js
291
server.js
@ -10,26 +10,29 @@ const { uploadTrendsCsv } = require('./s3');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// In-memory shift counters
|
// In‐memory shift counters
|
||||||
const shiftCounters = {};
|
const shiftCounters = {};
|
||||||
|
|
||||||
/** Helpers **/
|
// ===== Helpers =====
|
||||||
|
const pad2 = n => n.toString().padStart(2, '0');
|
||||||
|
|
||||||
// Format a JS Date in EST as SQL DATETIME
|
// Format Date in EST as “M/D/YY @HH:mm”
|
||||||
function formatDateEST(date) {
|
function shortEST(d) {
|
||||||
const pad = n => n.toString().padStart(2,'0');
|
const est = new Date(d.toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ` +
|
const M = est.getMonth() + 1, D = est.getDate(), YY = String(est.getFullYear()).slice(-2);
|
||||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
const hh = pad2(est.getHours()), mm = pad2(est.getMinutes());
|
||||||
|
return `${M}/${D}/${YY} @${hh}:${mm}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format a JS Date in EST as an ISO-like string (no Z)
|
// Format Date in EST as SQL DATETIME
|
||||||
function isoStringEST(date) {
|
function formatDateEST(d) {
|
||||||
const pad = n => n.toString().padStart(2,'0');
|
const est = new Date(d.toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}` +
|
const y = est.getFullYear(), M = pad2(est.getMonth()+1), D = pad2(est.getDate());
|
||||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
const hh = pad2(est.getHours()), mm = pad2(est.getMinutes()), ss = pad2(est.getSeconds());
|
||||||
|
return `${y}-${M}-${D} ${hh}:${mm}:${ss}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute heat index (NOAA formula)
|
// Compute heat index (NOAA)
|
||||||
function computeHeatIndex(T, R) {
|
function computeHeatIndex(T, R) {
|
||||||
const [c1,c2,c3,c4,c5,c6,c7,c8,c9] =
|
const [c1,c2,c3,c4,c5,c6,c7,c8,c9] =
|
||||||
[-42.379,2.04901523,10.14333127,-0.22475541,
|
[-42.379,2.04901523,10.14333127,-0.22475541,
|
||||||
@ -40,51 +43,49 @@ function computeHeatIndex(T, R) {
|
|||||||
return Math.round(HI * 100) / 100;
|
return Math.round(HI * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine shift (Day/Night), shiftStart, key & estNow in EST
|
// Determine shift info in EST
|
||||||
function getShiftInfo(now) {
|
function getShiftInfo(now) {
|
||||||
const estNow = new Date(now.toLocaleString('en-US',{ timeZone:'America/New_York' }));
|
const est = new Date(now.toLocaleString('en-US', { timeZone:'America/New_York' }));
|
||||||
const [h,m] = [estNow.getHours(), estNow.getMinutes()];
|
const h = est.getHours(), m = est.getMinutes();
|
||||||
let shift, shiftStart = new Date(estNow);
|
let shift, start = new Date(est);
|
||||||
|
|
||||||
if (h > 7 || (h === 7 && m >= 0)) {
|
if (h > 7 || (h === 7 && m >= 0)) {
|
||||||
if (h < 17 || (h === 17 && m < 30)) {
|
if (h < 17 || (h === 17 && m < 30)) {
|
||||||
shift = 'Day'; shiftStart.setHours(7,0,0,0);
|
shift = 'Day'; start.setHours(7,0,0,0);
|
||||||
} else {
|
} else {
|
||||||
shift = 'Night'; shiftStart.setHours(17,30,0,0);
|
shift = 'Night'; start.setHours(17,30,0,0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
shift = 'Night';
|
shift = 'Night';
|
||||||
shiftStart.setDate(shiftStart.getDate()-1);
|
start.setDate(start.getDate()-1);
|
||||||
shiftStart.setHours(17,30,0,0);
|
start.setHours(17,30,0,0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `${shift}-${shiftStart.toISOString().slice(0,10)}-` +
|
const key = `${shift}-${start.toISOString().slice(0,10)}-${start.getHours()}${start.getMinutes()}`;
|
||||||
`${shiftStart.getHours()}${shiftStart.getMinutes()}`;
|
return { shift, start, key, estNow: est };
|
||||||
return { shift, shiftStart, key, estNow };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch current weather from OpenWeatherMap
|
// Fetch current weather from OpenWeatherMap
|
||||||
async function fetchCurrentWeather() {
|
async function fetchCurrentWeather() {
|
||||||
const apiKey = process.env.WEATHER_API_KEY;
|
const key = process.env.WEATHER_API_KEY;
|
||||||
const zip = process.env.ZIP_CODE;
|
const zip = process.env.ZIP_CODE;
|
||||||
if (!apiKey || !zip) return null;
|
if (!key || !zip) return null;
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(
|
const { data } = await axios.get(
|
||||||
'https://api.openweathermap.org/data/2.5/weather',
|
'https://api.openweathermap.org/data/2.5/weather',
|
||||||
{ params: { zip:`${zip},us`, appid:apiKey, units:'imperial' } }
|
{ params: { zip:`${zip},us`, appid:key, units:'imperial' } }
|
||||||
);
|
);
|
||||||
return {
|
const desc = data.weather[0].description;
|
||||||
description: data.weather[0].description,
|
const hi = Math.round(data.main.temp_max);
|
||||||
humidity: data.main.humidity,
|
const hum = data.main.humidity;
|
||||||
temperature: data.main.temp
|
return `${desc.charAt(0).toUpperCase()+desc.slice(1)}. Hi of ${hi}, Humidity ${hum}%`;
|
||||||
};
|
} catch (e) {
|
||||||
} catch (err) {
|
console.error('Weather API error:', e.message);
|
||||||
console.error('Weather API error:', err.message);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** MariaDB pool **/
|
// MariaDB pool
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
port: parseInt(process.env.DB_PORT,10) || 3306,
|
port: parseInt(process.env.DB_PORT,10) || 3306,
|
||||||
@ -94,30 +95,25 @@ const pool = mysql.createPool({
|
|||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
connectTimeout: 10000,
|
connectTimeout: 10000
|
||||||
enableKeepAlive: true,
|
|
||||||
keepAliveInitialDelay:10000
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure readings table exists (for dock doors & areas)
|
// Ensure table exists
|
||||||
(async () => {
|
(async () => {
|
||||||
const createSQL = `
|
const sql = `
|
||||||
CREATE TABLE IF NOT EXISTS readings (
|
CREATE TABLE IF NOT EXISTS readings (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
dockDoor INT,
|
location VARCHAR(20) NOT NULL,
|
||||||
direction VARCHAR(10),
|
stationDockDoor VARCHAR(10) NOT NULL,
|
||||||
region VARCHAR(20),
|
|
||||||
stationCode VARCHAR(10),
|
|
||||||
timestamp DATETIME NOT NULL,
|
timestamp DATETIME NOT NULL,
|
||||||
temperature DOUBLE,
|
temperature DOUBLE,
|
||||||
humidity DOUBLE,
|
humidity DOUBLE,
|
||||||
heatIndex DOUBLE
|
heatIndex DOUBLE
|
||||||
);
|
);`;
|
||||||
`;
|
await pool.execute(sql);
|
||||||
await pool.execute(createSQL);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/** SSE for real-time updates **/
|
// SSE setup
|
||||||
let clients = [];
|
let clients = [];
|
||||||
app.get('/api/stream',(req,res)=>{
|
app.get('/api/stream',(req,res)=>{
|
||||||
res.set({
|
res.set({
|
||||||
@ -129,80 +125,88 @@ app.get('/api/stream',(req,res)=>{
|
|||||||
clients.push(res);
|
clients.push(res);
|
||||||
req.on('close',()=>{ clients = clients.filter(c=>c!==res); });
|
req.on('close',()=>{ clients = clients.filter(c=>c!==res); });
|
||||||
});
|
});
|
||||||
function broadcast(event,data){
|
function broadcast(evt,data){
|
||||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
const msg = `event: ${evt}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
clients.forEach(c=>c.write(msg));
|
clients.forEach(c=>c.write(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware & static
|
// Middleware
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
app.use(express.static(path.join(__dirname,'public')));
|
app.use(express.static(path.join(__dirname,'public')));
|
||||||
|
|
||||||
/** Dual-dock-door readings **/
|
const publicDir = path.join(__dirname, 'public');
|
||||||
|
// Serve heatmap.html as the index page
|
||||||
|
app.use(express.static(publicDir, { index: 'heatmap.html' }));
|
||||||
|
|
||||||
|
|
||||||
|
// ---- Dual dock-door endpoint ----
|
||||||
app.post('/api/readings', async (req, res) => {
|
app.post('/api/readings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { inbound={}, outbound={} } = req.body;
|
const { inbound={}, outbound={} } = req.body;
|
||||||
const { dockDoor: inDoor, temperature: inTemp, humidity: inHum } = inbound;
|
const { dockDoor: inD, temperature: inT, humidity: inH } = inbound;
|
||||||
const { dockDoor: outDoor,temperature: outTemp,humidity: outHum } = outbound;
|
const { dockDoor: outD, temperature: outT, humidity: outH } = outbound;
|
||||||
if ([inDoor,inTemp,inHum,outDoor,outTemp,outHum].some(v=>v===undefined))
|
if ([inD,inT,inH,outD,outT,outH].some(v=>v==null))
|
||||||
return res.status(400).json({error:'Missing inbound/outbound fields'});
|
return res.status(400).json({ error:'Missing fields' });
|
||||||
|
|
||||||
const hiIn = computeHeatIndex(inTemp,inHum);
|
const hiIn = computeHeatIndex(inT,inH);
|
||||||
const hiOut = computeHeatIndex(outTemp,outHum);
|
const hiOut = computeHeatIndex(outT,outH);
|
||||||
const { shift, shiftStart, key, estNow } = getShiftInfo(new Date());
|
const now = new Date();
|
||||||
|
const { shift, start, key, estNow } = getShiftInfo(now);
|
||||||
shiftCounters[key] = (shiftCounters[key]||0)+1;
|
shiftCounters[key] = (shiftCounters[key]||0)+1;
|
||||||
const period = shiftCounters[key];
|
const period = shiftCounters[key];
|
||||||
|
|
||||||
const sqlTs = formatDateEST(estNow);
|
const sqlTs = formatDateEST(estNow);
|
||||||
const bcTs = isoStringEST(estNow);
|
const shortTs = shortEST(estNow);
|
||||||
|
|
||||||
// Insert dock readings
|
// Insert inbound & outbound
|
||||||
const insertSQL = `
|
const ins = `INSERT INTO readings(location,stationDockDoor,timestamp,temperature,humidity,heatIndex)
|
||||||
INSERT INTO readings
|
VALUES(?,?,?,?,?,?)`;
|
||||||
(dockDoor,direction,timestamp,temperature,humidity,heatIndex)
|
await pool.execute(ins, ['Inbound', String(inD), sqlTs, inT, inH, hiIn]);
|
||||||
VALUES (?,?,?,?,?,?)
|
await pool.execute(ins, ['Outbound', String(outD),sqlTs, outT,outH,hiOut]);
|
||||||
`;
|
|
||||||
await pool.execute(insertSQL,[inDoor,'inbound',sqlTs,inTemp,inHum,hiIn]);
|
|
||||||
await pool.execute(insertSQL,[outDoor,'outbound',sqlTs,outTemp,outHum,hiOut]);
|
|
||||||
|
|
||||||
// Broadcast SSE
|
// SSE
|
||||||
broadcast('new-reading',{ dockDoor:inDoor, direction:'inbound',timestamp:bcTs,
|
broadcast('new-reading', { location:'Inbound', stationDockDoor:String(inD), timestamp: shortTs, temperature:inT, humidity:inH, heatIndex:hiIn });
|
||||||
temperature:inTemp, humidity:inHum, heatIndex:hiIn });
|
broadcast('new-reading', { location:'Outbound', stationDockDoor:String(outD), timestamp: shortTs, temperature:outT, humidity:outH, heatIndex:hiOut });
|
||||||
broadcast('new-reading',{ dockDoor:outDoor,direction:'outbound',timestamp:bcTs,
|
|
||||||
temperature:outTemp,humidity:outHum,heatIndex:hiOut });
|
|
||||||
|
|
||||||
// Build CSV & upload
|
// CSV upload
|
||||||
const dateKey = `${estNow.getFullYear()}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`;
|
const y=estNow.getFullYear(), m=pad2(estNow.getMonth()+1), d=pad2(estNow.getDate());
|
||||||
const [rows] = await pool.execute(
|
const dateKey = `${y}${m}${d}`;
|
||||||
`SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp ASC`
|
const [rows] = await pool.execute(`SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp`);
|
||||||
);
|
|
||||||
let csvUrl=null;
|
let csvUrl=null;
|
||||||
try { csvUrl = await uploadTrendsCsv(dateKey, rows); }
|
try { csvUrl = await uploadTrendsCsv(dateKey, rows); }
|
||||||
catch(e){ console.error('CSV upload error:',e); }
|
catch(e){ console.error('CSV upload error',e); }
|
||||||
|
|
||||||
// Weather
|
// Weather
|
||||||
const weather = await fetchCurrentWeather();
|
const weather = await fetchCurrentWeather() || 'Unavailable';
|
||||||
|
|
||||||
// Slack payload
|
// Slack payload
|
||||||
const slackPayload = {
|
const text = `*_${shift} shift Period ${period} dock/ trailer temperature checks for ${shortTs}_*\n` +
|
||||||
text: 'New temperature readings recorded',
|
`*_Current Weather Forecast for Baltimore: ${weather}_*\n\n` +
|
||||||
inbound_dockDoor: inDoor,
|
`*_⬇️ Inbound Dock Door 🚛 :_* ${inD}\n` +
|
||||||
inbound_temperature: inTemp,
|
`*_Temp:_* ${inT} °F 🌡️\n` +
|
||||||
inbound_humidity: inHum,
|
`*_Humidity:_* ${inH} % 💦\n` +
|
||||||
hiIn,
|
`*_Heat Index:_* ${hiIn} °F 🥵\n\n` +
|
||||||
outbound_dockDoor: outDoor,
|
`*_⬆️ Outbound Dock Door 🚛 :_* ${outD}\n` +
|
||||||
outbound_temperature:outTemp,
|
`*_Temp:_* ${outT} °F 🌡️\n` +
|
||||||
outbound_humidity: outHum,
|
`*_Humidity:_* ${outH} % 💦\n` +
|
||||||
hiOut,
|
`*_Heat Index:_* ${hiOut} °F 🥵`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text,
|
||||||
shift,
|
shift,
|
||||||
period,
|
period,
|
||||||
timestamp: bcTs,
|
timestamp: shortTs,
|
||||||
csvUrl,
|
current_weather: weather,
|
||||||
current_weather: weather?.description || null,
|
inbound_dock_door: inD,
|
||||||
current_humidity: weather?.humidity || null,
|
inbound_temperature: inT,
|
||||||
current_temperature: weather?.temperature || null
|
inbound_humidity: inH,
|
||||||
|
inbound_heat_index: hiIn,
|
||||||
|
outbound_dock_door: outD,
|
||||||
|
outbound_temperature: outT,
|
||||||
|
outbound_humidity: outH,
|
||||||
|
outbound_heat_index: hiOut
|
||||||
};
|
};
|
||||||
await axios.post(process.env.SLACK_WEBHOOK_URL,slackPayload);
|
await axios.post(process.env.SLACK_WEBHOOK_URL, payload);
|
||||||
|
|
||||||
res.json({ success:true, shift, period, csvUrl });
|
res.json({ success:true, shift, period, csvUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -211,73 +215,72 @@ app.post('/api/readings',async(req,res)=>{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Area (A-Mod / AFE-1 / AFE-2 / B-Mod) readings **/
|
// ---- Area (Mod/AFE) endpoint ----
|
||||||
app.post('/api/area-readings', async (req, res) => {
|
app.post('/api/area-readings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { area, stationCode, temperature, humidity } = req.body;
|
const { area, stationCode, temperature: T, humidity: H } = req.body;
|
||||||
if (!area||!stationCode||temperature==null||humidity==null)
|
if (!area || !stationCode || T==null || H==null)
|
||||||
return res.status(400).json({error:'Missing area,stationCode,temp,humidity'});
|
return res.status(400).json({ error:'Missing fields' });
|
||||||
|
|
||||||
const hi = computeHeatIndex(temperature,humidity);
|
const hi = computeHeatIndex(T,H);
|
||||||
const { shift, shiftStart, key, estNow } = getShiftInfo(new Date());
|
const now = new Date();
|
||||||
shiftCounters[key]=(shiftCounters[key]||0)+1;
|
const { shift, start, key, estNow } = getShiftInfo(now);
|
||||||
const period = shiftCounters[key];
|
|
||||||
|
// NOTE: area checks do NOT increment period counter
|
||||||
|
const shortTs = shortEST(estNow);
|
||||||
const sqlTs = formatDateEST(estNow);
|
const sqlTs = formatDateEST(estNow);
|
||||||
const bcTs = isoStringEST(estNow);
|
|
||||||
|
|
||||||
// Insert area reading
|
// Insert
|
||||||
const sql = `
|
const ins = `INSERT INTO readings(location,stationDockDoor,timestamp,temperature,humidity,heatIndex)
|
||||||
INSERT INTO readings
|
VALUES(?,?,?,?,?,?)`;
|
||||||
(region,stationCode,timestamp,temperature,humidity,heatIndex)
|
await pool.execute(ins, [area, stationCode, sqlTs, T, H, hi]);
|
||||||
VALUES (?,?,?,?,?,?)
|
|
||||||
`;
|
|
||||||
await pool.execute(sql,[area,stationCode,sqlTs,temperature,humidity,hi]);
|
|
||||||
|
|
||||||
// Broadcast SSE
|
// SSE
|
||||||
broadcast('new-area-reading',{ region:area,stationCode,timestamp:bcTs,
|
broadcast('new-area-reading', { location:area, stationDockDoor:stationCode, timestamp:shortTs, temperature:T, humidity:H, heatIndex:hi });
|
||||||
temperature,humidity,heatIndex:hi });
|
|
||||||
|
|
||||||
// Build CSV & upload
|
// CSV upload
|
||||||
const dateKey = `${estNow.getFullYear()}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`;
|
const y=estNow.getFullYear(), m=pad2(estNow.getMonth()+1), d=pad2(estNow.getDate());
|
||||||
const [rows] = await pool.execute(
|
const dateKey = `${y}${m}${d}`;
|
||||||
`SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp ASC`
|
const [rows] = await pool.execute(`SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp`);
|
||||||
);
|
|
||||||
let csvUrl=null;
|
let csvUrl=null;
|
||||||
try { csvUrl = await uploadTrendsCsv(dateKey, rows); }
|
try { csvUrl = await uploadTrendsCsv(dateKey, rows); }
|
||||||
catch(e){ console.error('CSV upload error:',e); }
|
catch(e){ console.error('CSV upload error',e); }
|
||||||
|
|
||||||
// Weather
|
// Weather
|
||||||
const weather = await fetchCurrentWeather();
|
const weather = await fetchCurrentWeather() || 'Unavailable';
|
||||||
|
|
||||||
// Slack payload
|
// Slack text
|
||||||
const slackPayload = {
|
const text = `*_${shift} shift ${area} temp check for ${shortTs}_*\n` +
|
||||||
text: 'New area temperature reading',
|
`*_Current Weather Forecast for Baltimore: ${weather}_*\n\n` +
|
||||||
area,
|
`*_${area.toUpperCase()} station:_* ${stationCode}\n` +
|
||||||
stationCode,
|
`*_Temp:_* ${T} °F 🌡️\n` +
|
||||||
temperature,
|
`*_Humidity:_* ${H} % 💦\n` +
|
||||||
humidity,
|
`*_Heat Index:_* ${hi} °F 🥵`;
|
||||||
heatIndex: hi,
|
|
||||||
|
const payload = {
|
||||||
|
text,
|
||||||
shift,
|
shift,
|
||||||
period,
|
timestamp: shortTs,
|
||||||
timestamp: bcTs,
|
current_weather: weather,
|
||||||
csvUrl,
|
location: area,
|
||||||
current_weather: weather?.description || null,
|
station_dock_door: stationCode,
|
||||||
current_humidity: weather?.humidity || null,
|
temperature: T,
|
||||||
current_temperature: weather?.temperature || null
|
humidity: H,
|
||||||
|
heat_index: hi
|
||||||
};
|
};
|
||||||
await axios.post(process.env.SLACK_WEBHOOK_URL,slackPayload);
|
await axios.post(process.env.SLACK_WEBHOOK_URL, payload);
|
||||||
|
|
||||||
res.json({ success:true, shift,period,csvUrl });
|
res.json({ success:true, csvUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /api/area-readings error:', err);
|
console.error('POST /api/area-readings error:', err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Return all readings **/
|
// ---- GET all readings ----
|
||||||
app.get('/api/readings', async (req, res) => {
|
app.get('/api/readings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
|
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp`);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('GET /api/readings error:', err);
|
console.error('GET /api/readings error:', err);
|
||||||
@ -285,16 +288,16 @@ app.get('/api/readings',async(req,res)=>{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Export CSV of all readings **/
|
// ---- CSV export ----
|
||||||
app.get('/api/export', async (req, res) => {
|
app.get('/api/export', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
|
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp`);
|
||||||
res.setHeader('Content-disposition','attachment; filename=readings.csv');
|
res.setHeader('Content-disposition','attachment; filename=readings.csv');
|
||||||
res.set('Content-Type','text/csv');
|
res.set('Content-Type','text/csv');
|
||||||
res.write('id,dockDoor,direction,region,stationCode,timestamp,temperature,humidity,heatIndex\n');
|
res.write('id,location,stationDockDoor,timestamp,temperature,humidity,heatIndex\n');
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
const ts = r.timestamp instanceof Date ? formatDateEST(r.timestamp) : r.timestamp;
|
const ts = r.timestamp instanceof Date ? formatDateEST(r.timestamp) : r.timestamp;
|
||||||
res.write(`${r.id},${r.dockDoor||''},${r.direction||''},${r.region||''},${r.stationCode||''},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`);
|
res.write(`${r.id},${r.location},${r.stationDockDoor},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`);
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user