docker 0.1.2

This commit is contained in:
joshbaney 2025-04-29 08:16:29 -04:00
parent 9e25a38a29
commit c372146ba8
7 changed files with 583 additions and 543 deletions

4
.env
View File

@ -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

View File

@ -8,39 +8,53 @@
<title>Heat Map</title> <title>Heat Map</title>
</head> </head>
<body> <body>
<header class="main-header">
<!-- BINS-style fixed 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>
<button class="nav-btn" onclick="location.href='/input.html'">Log Reading</button> <button class="nav-btn" onclick="location.href='/input.html'">Log Reading</button>
<button class="nav-btn" onclick="location.href='/heatmap.html'">Heat Map</button> <button class="nav-btn" onclick="location.href='/heatmap.html'">Heat Map</button>
<button class="nav-btn" onclick="location.href='/trends.html'">Trends</button> <button class="nav-btn" onclick="location.href='/trends.html'">Trends</button>
<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>
<script src="scripts/heatmap.js"></script> <script src="scripts/heatmap.js"></script>
</body> </body>
</html> </html>

View File

@ -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>

View File

@ -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 24
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 24
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);
sq.addEventListener('mouseleave', hideTip);
});
// Regions
Object.entries(regionEls).forEach(([r, el]) => {
el.addEventListener('mouseenter', e => {
const stats = computeStats(regionData[r]);
if (!stats) return;
const lr = stats.latest;
const html = `
<strong>${r}</strong><br>
Latest Station: ${lr.stationCode}<br>
HI ${lr.heatIndex}, T ${lr.temperature}°F, H ${lr.humidity}%<br>
Time: ${new Date(lr.timestamp).toLocaleString()}<br>
Max HI: ${stats.max.heatIndex}, Min HI: ${stats.min.heatIndex}<br>
Avg HI: ${stats.avg}
`;
showTip(e, html);
});
el.addEventListener('mousemove', moveTip);
el.addEventListener('mouseleave', hideTip);
});
}
// ==== Zoom & Pan via CSS Vars ====
function setupZoom() {
const root = document.documentElement.style;
let scale = 1;
const init = {
size: 60,
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`);
} }
// station-square
if (tgt.classList.contains('station-square')) {
let stats = null, regionName = null;
for (const [name, arr] of Object.entries(regionData)) {
if (arr.some(r => r.stationDockDoor === tgt.textContent)) {
regionName = name;
stats = computeStats(arr.filter(r => r.stationDockDoor === tgt.textContent));
break;
}
}
if (!stats) return hideTooltip();
const { latest, max, min, avg } = stats;
const html = `
<strong>${regionName} ${tgt.textContent}</strong><br>
Latest: HI ${latest.heatIndex}, T ${latest.temperature}°F, H ${latest.humidity}%<br>
Time: ${new Date(latest.timestamp).toLocaleString()}<br>
Max HI: ${max.heatIndex} @ ${new Date(max.timestamp).toLocaleString()}<br>
Min HI: ${min.heatIndex} @ ${new Date(min.timestamp).toLocaleString()}<br>
Avg HI: ${avg}
`;
return showTooltip(html, e.pageX, e.pageY);
}
hideTooltip();
}); });
diagram.addEventListener('mouseleave', hideTooltip);
} }
// ==== Init ==== // ==== INITIALIZATION ====
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', async () => {
createGrid(); buildDockRow();
loadInitial(); await loadInitial();
setupFloorSelector(val => renderFloor(val));
renderFloor(1);
subscribeRealtime(); subscribeRealtime();
setupTooltips(); setupTooltips();
setupZoom();
}); });

View File

@ -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));
}); });

View File

@ -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;
} }

339
server.js
View File

@ -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 // Inmemory 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,
@ -37,87 +40,80 @@ function computeHeatIndex(T, R) {
const HI = c1 + c2*T + c3*R + c4*T*R const HI = c1 + c2*T + c3*R + c4*T*R
+ c5*T*T + c6*R*R + c7*T*T*R + c5*T*T + c6*R*R + c7*T*T*R
+ c8*T*R*R + c9*T*T*R*R; + c8*T*R*R + c9*T*T*R*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,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
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({
@ -127,183 +123,190 @@ app.get('/api/stream',(req,res)=>{
}); });
res.flushHeaders(); res.flushHeaders();
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');
app.post('/api/readings',async(req,res)=>{ // 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) => {
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();
shiftCounters[key]=(shiftCounters[key]||0)+1; const { shift, start, key, estNow } = getShiftInfo(now);
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) {
console.error('POST /api/readings error:',err); console.error('POST /api/readings error:', err);
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
/** 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);
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
/** 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) {
console.error('GET /api/export error:',err); console.error('GET /api/export error:', err);
res.status(500).send(err.message); res.status(500).send(err.message);
} }
}); });
// Start server // Start server
app.listen(PORT,()=>{ app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://localhost:${PORT}`);
}); });