Compare commits

...

No commits in common. "feature/docker-0.1.1" and "main" have entirely different histories.

16 changed files with 572 additions and 1136 deletions

8
.env
View File

@ -1,13 +1,13 @@
# 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
DB_HOST=0.0.0.0 DB_HOST=172.23.0.3
DB_PORT=3307 DB_PORT=3306
DB_USER=joshbaney DB_USER=joshbaney
DB_PASSWORD=Ran0dal5! DB_PASSWORD=Ran0dal5!
DB_NAME=heatmap DB_NAME=heatmap

View File

@ -1,16 +0,0 @@
# Dockerfile
FROM node:18-alpine
# Create app directory
WORKDIR /usr/src/app
# Install dependencies
COPY package*.json ./
RUN npm install --production
# Bundle app source
COPY . .
# Expose port and run
EXPOSE 3000
CMD ["node", "server.js"]

@ -1 +0,0 @@
Subproject commit b2b8b8bde45246778e29da4cc23bc0bdd3c66ff5

View File

@ -1,14 +0,0 @@
version: '3.8'
services:
fuego-app:
build:
context: .
dockerfile: Dockerfile
extra_hosts:
- "host.docker.internal:host-gateway"
env_file:
- .env
ports:
- "3000:3000"
restart: unless-stopped

View File

@ -1,18 +1,14 @@
{ {
"name": "Amazon-Fuego", "name": "warehouse-heatmap-app",
"version": "1.0.0", "version": "1.0.0",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.300.0",
"axios": "^1.4.0",
"body-parser": "^1.20.2",
"csv-stringify": "^6.0.8",
"dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"mysql2": "^3.3.3", "mysql2": "^3.2.0",
"sqlite3": "^5.1.6" "knex": "^2.4.2",
"body-parser": "^1.20.2"
}
} }
}

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,72 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="styles.css">
<title>Log Area Reading</title>
</head>
<body>
<header class="main-header">
<img src="/image/logo.png" alt="Amazon Logo" class="logo">
<span class="header-title"> | Fuego - Heat Tracker</span>
<button class="nav-btn" onclick="location.href='/input.html'">Dock Reading</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='/input-area.html'">Area Reading</button>
</header>
<div class="page-container">
<h1>Enter Area Reading</h1>
<form id="area-form">
<fieldset>
<legend>Area Selection</legend>
<select id="area" class="form-input" required>
<option value="" disabled selected>Select an area</option>
<option value="A-Mod">A-Mod</option>
<option value="AFE-1">AFE-1 (Outbound Pre-Slam Floor 1)</option>
<option value="AFE-2">AFE-2 (Outbound Pre-Slam Floor 2)</option>
<option value="B-Mod">B-Mod</option>
</select>
</fieldset>
<fieldset>
<legend>Station Code</legend>
<input
id="stationCode"
class="form-input"
type="text"
placeholder="4-digit code (e.g. 4305)"
pattern="\d{4}"
required
>
<small style="display:block; text-align:center; color:#666;">
Format: &lt;Floor (14)&gt;&lt;Dir (1=N,2=E,3=S,4=W)&gt;&lt;Station # (2 digits)&gt;
</small>
</fieldset>
<fieldset>
<legend>Measurements</legend>
<input
id="temperature"
class="form-input"
type="number"
step="0.1"
placeholder="Temperature (°F)"
required
>
<input
id="humidity"
class="form-input"
type="number"
step="0.1"
placeholder="Humidity (%)"
required
>
</fieldset>
<button type="submit" class="big-button">Submit Area Reading</button>
</form>
</div>
<script src="scripts/input-area.js"></script>
</body>
</html>

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<title>Log Dual Readings</title> <title>Log Dual Readings</title>
@ -14,7 +14,6 @@
<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>
</header> </header>
<div class="page-container"> <div class="page-container">
@ -22,26 +21,19 @@
<form id="reading-form"> <form id="reading-form">
<fieldset> <fieldset>
<legend>Inbound</legend> <legend>Inbound</legend>
<input class="form-input" id="inboundDoor" type="number" <input class="form-input" id="inboundDoor" type="number" placeholder="Dock Door # (124138,202209)" required>
placeholder="Dock Door # (124138, 202209)" required> <input class="form-input" id="inboundTemp" type="number" step="0.1" placeholder="Temperature (°F)" required>
<input class="form-input" id="inboundTemp" type="number" step="0.1" <input class="form-input" id="inboundHum" type="number" step="0.1" placeholder="Humidity (%)" required>
placeholder="Temperature (°F)" required>
<input class="form-input" id="inboundHum" type="number" step="0.1"
placeholder="Humidity (%)" required>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Outbound</legend> <legend>Outbound</legend>
<input class="form-input" id="outboundDoor" type="number" <input class="form-input" id="outboundDoor" type="number" placeholder="Dock Door # (142201)" required>
placeholder="Dock Door # (142201)" required> <input class="form-input" id="outboundTemp" type="number" step="0.1" placeholder="Temperature (°F)" required>
<input class="form-input" id="outboundTemp" type="number" step="0.1" <input class="form-input" id="outboundHum" type="number" step="0.1" placeholder="Humidity (%)" required>
placeholder="Temperature (°F)" required>
<input class="form-input" id="outboundHum" type="number" step="0.1"
placeholder="Humidity (%)" required>
</fieldset> </fieldset>
<button type="submit" class="big-button">Submit Both</button> <button type="submit" class="big-button">Submit Both</button>
</form> </form>
</div> </div>
<script src="scripts/input.js"></script> <script src="scripts/input.js"></script>
</body> </body>
</html> </html>

View File

@ -1,199 +1,51 @@
// public/scripts/heatmap.js // door ranges
const doors = [
// ==== Configuration ====
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 // NOAA heatindex formula
const regionEls = { function getColorFromHI(H) {
'A-Mod': document.getElementById('region-amod'),
'Outbound Pre-Slam':document.getElementById('region-preslam'),
'B-Mod': document.getElementById('region-bmod'),
};
// ==== State Stores ====
const doorData = {}; // dockDoor → array of readings
const regionData = {}; // regionName → array of readings
// Initialize data arrays
dockDoors.forEach(d => doorData[d] = []);
Object.keys(regionEls).forEach(r => regionData[r] = []);
// ==== Color Helper ====
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;
const 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.8)`;
} }
// ==== Stats Helper ==== function createGrid() {
function computeStats(arr) {
if (!arr.length) return null;
let sum = 0, max = arr[0], min = arr[0];
arr.forEach(r => {
sum += r.heatIndex;
if (r.heatIndex > max.heatIndex) max = r;
if (r.heatIndex < min.heatIndex) min = r;
});
const avg = (sum / arr.length).toFixed(2);
const latest = arr[arr.length - 1];
return { latest, max, min, avg };
}
// ==== Grid Creation ====
function createGrid() {
const row = document.getElementById('dock-row'); const row = document.getElementById('dock-row');
dockDoors.forEach(d => { doors.forEach(d => {
const sq = document.createElement('div'); const sq = document.createElement('div');
sq.className = 'dock-square'; sq.className = 'dock-square';
sq.dataset.door = d; sq.dataset.door = d;
sq.textContent = d;
row.appendChild(sq); row.appendChild(sq);
}); });
}
// ==== Initial Load ====
async function loadInitial() {
const all = await fetch('/api/readings').then(r=>r.json());
all.forEach(r => {
if (r.dockDoor != null) {
doorData[r.dockDoor].push(r);
} else if (r.region) {
regionData[r.region].push(r);
}
});
// Color each dock
Object.entries(doorData).forEach(([d, arr]) => {
const stats = computeStats(arr);
if (stats) {
const el = document.querySelector(`.dock-square[data-door="${d}"]`);
el.style.background = getColorFromHI(stats.latest.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 ====
function subscribeRealtime() {
const es = new EventSource('/api/stream');
es.addEventListener('new-reading', e => {
const r = JSON.parse(e.data);
if (doorData[r.dockDoor]) {
doorData[r.dockDoor].push(r);
const el = document.querySelector(`.dock-square[data-door="${r.dockDoor}"]`);
el.style.background = getColorFromHI(r.heatIndex);
}
});
es.addEventListener('new-area-reading', e => {
const r = JSON.parse(e.data);
if (regionData[r.region]) {
regionData[r.region].push(r);
const el = regionEls[r.region];
el.style.background = getColorFromHI(r.heatIndex);
}
});
}
// ==== Tooltip Setup ====
function setupTooltips() {
const tooltip = document.getElementById('tooltip');
// Common hover handlers
function showTip(e, infoHtml) {
tooltip.innerHTML = infoHtml;
tooltip.style.display = 'block';
}
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 // apply a new reading to its square
document.querySelectorAll('.dock-square').forEach(sq => { function colorize(door, hi) {
sq.addEventListener('mouseenter', e => { const sq = document.querySelector(`.dock-square[data-door="${door}"]`);
const d = Number(sq.dataset.door); if (!sq) return;
const stats = computeStats(doorData[d]); sq.style.background = getColorFromHI(hi);
if (!stats) return;
const lt = new Date(stats.latest.timestamp).toLocaleString();
const ht = new Date(stats.max.timestamp).toLocaleString();
const lt2= new Date(stats.min.timestamp).toLocaleString();
const html = `
<strong>Door ${d}</strong><br>
Latest: HI ${stats.latest.heatIndex}, T ${stats.latest.temperature}°F, H ${stats.latest.humidity}%<br>
Time: ${lt}<br>
Max: HI ${stats.max.heatIndex} at ${ht}<br>
Min: HI ${stats.min.heatIndex} at ${lt2}<br>
Avg HI: ${stats.avg}
`;
showTip(e, html);
});
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`);
} }
});
}
// ==== Init ==== async function init() {
document.addEventListener('DOMContentLoaded', () => {
createGrid(); createGrid();
loadInitial(); // initial fill
subscribeRealtime(); const all = await fetch('/api/readings').then(r=>r.json());
setupTooltips(); // pick latest per door
setupZoom(); const latest = {};
}); all.forEach(r => { latest[r.dockDoor] = r.heatIndex; });
Object.entries(latest).forEach(([door, hi]) => colorize(door, hi));
// subscribe SSE
const es = new EventSource('/api/stream');
es.addEventListener('new-reading', e => {
const { dockDoor, heatIndex } = JSON.parse(e.data);
colorize(dockDoor, heatIndex);
});
}
document.addEventListener('DOMContentLoaded', init);

View File

@ -1,40 +0,0 @@
// public/scripts/input-area.js
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('area-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Gather form values
const area = document.getElementById('area').value;
const stationCode = document.getElementById('stationCode').value;
const temperature = parseFloat(document.getElementById('temperature').value);
const humidity = parseFloat(document.getElementById('humidity').value);
// Build payload
const payload = { area, stationCode, temperature, humidity };
try {
const res = await fetch('/api/area-readings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const { error } = await res.json();
alert(`Error: ${error}`);
return;
}
// Success: reset form and optionally notify user
form.reset();
alert('Area reading submitted successfully!');
} catch (err) {
console.error('Network error submitting area reading:', err);
alert('Network error please try again.');
}
});
});

View File

@ -1,161 +1,135 @@
// average helper // public/scripts/trends.js
function average(arr) {
return arr.reduce((a, b) => a + b, 0) / arr.length; // ─── Timeframes ──────────────────────────────────────────────────────────────
const tfConfig = [
{ unit:'hours', count:24, label:'Last 24 Hours' },
{ unit:'days', count:7, label:'Last 7 Days' },
{ unit:'weeks', count:4, label:'Last 4 Weeks' },
{ unit:'months', count:12, label:'Last 12 Months' },
{ unit:'years', count:1, label:'All Time' }
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatEST(epoch) {
return new Date(epoch).toLocaleString('en-US', {
timeZone: 'America/New_York',
month: 'numeric',
day: 'numeric',
year: '2-digit',
hour12: false,
hour: '2-digit',
minute: '2-digit'
}).replace(',', ' @');
}
function subtract(date, count, unit) {
const d = new Date(date);
switch(unit){
case 'hours': d.setHours(d.getHours() - count); break;
case 'days': d.setDate(d.getDate() - count); break;
case 'weeks': d.setDate(d.getDate() - 7*count); break;
case 'months': d.setMonth(d.getMonth() - count); break;
case 'years': d.setFullYear(d.getFullYear() - count); break;
}
return d;
} }
// interval mappings // ─── State ────────────────────────────────────────────────────────────────────
const periodKeys = ['hourly','daily','weekly','monthly','yearly']; let readings = [];
const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly']; let chart;
// bucket definitions // ─── On Load ─────────────────────────────────────────────────────────────────
const periodConfig = { document.addEventListener('DOMContentLoaded', async() => {
hourly: { keyFn: r => r.timestamp.slice(0,13), labelFn: k => k.replace('T',' ') }, const data = await fetch('/api/readings').then(r=>r.json());
daily: { keyFn: r => r.timestamp.slice(0,10), labelFn: k => k }, readings = data.map(r=>({
weekly: { keyFn: r => { ...r,
const d = new Date(r.timestamp), y = d.getFullYear(); ts: formatEST(r.epoch_ms),
const w = Math.ceil((((d - new Date(y,0,1))/864e5) + new Date(y,0,1).getDay()+1)/7); date: new Date(r.epoch_ms)
return `${y}-W${w}`; }));
}, labelFn: k => k }, setupUI(); initChart(); updateView();
monthly: { keyFn: r => r.timestamp.slice(0,7), labelFn: k => k }, });
yearly: { keyFn: r => r.timestamp.slice(0,4), labelFn: k => k }
};
// global Chart.js instance // ─── UI Wiring ────────────────────────────────────────────────────────────────
let trendChart, dataTable; function setupUI(){
const slider = document.getElementById('timeframe-slider');
const label = document.getElementById('timeframe-label');
slider.addEventListener('input',()=>{
label.textContent = tfConfig[slider.value].label;
updateView();
});
label.textContent = tfConfig[slider.value].label;
// fetch readings from server document.getElementsByName('metric').forEach(cb=>cb.addEventListener('change',updateView));
async function fetchReadings() {
const res = await fetch('/api/readings'); document.querySelectorAll('#trends-table thead th.sortable').forEach(th=>{
return res.ok ? res.json() : []; th.addEventListener('click', ()=>{
const idx = th.cellIndex;
const asc = !th.classList.contains('asc');
const tbody = document.getElementById('trends-table-body');
const rows = Array.from(tbody.rows);
rows.sort((a,b)=>{
return asc
? a.cells[idx].textContent.localeCompare(b.cells[idx].textContent,undefined,{numeric:true})
: b.cells[idx].textContent.localeCompare(a.cells[idx].textContent,undefined,{numeric:true});
});
th.classList.toggle('asc',asc);
rows.forEach(r=>tbody.appendChild(r));
});
});
} }
// determine direction based on dock door # // ─── Chart.js Setup ──────────────────────────────────────────────────────────
function getDirection(dock) { function initChart(){
return ( (dock>=124 && dock<=138) || (dock>=202 && dock<=209) ) const ctx = document.getElementById('trend-chart').getContext('2d');
? 'Inbound' chart = new Chart(ctx,{
: 'Outbound'; type:'line',
data:{labels:[],datasets:[]},
options:{
scales:{
x:{ title:{display:true,text:'Time (EST)'} },
y:{ title:{display:true,text:'Value'} }
},
interaction:{mode:'index',intersect:false},
plugins:{legend:{position:'top'}},
maintainAspectRatio:false
}
});
} }
// render DataTable // ─── Render ───────────────────────────────────────────────────────────────────
function renderTable(allReadings) { function updateView(){
const tbody = $('#trendTable tbody').empty(); const idx = +document.getElementById('timeframe-slider').value;
allReadings.forEach(r => { const {unit,count} = tfConfig[idx];
const ts = new Date(r.timestamp).toLocaleString('en-US', { timeZone:'Europe/London' }); const cutoff = subtract(Date.now(),count,unit);
const dir = getDirection(r.dockDoor);
tbody.append(` const filtered = readings.filter(r=>r.date>=cutoff);
<tr> const selected = Array.from(document.getElementsByName('metric'))
<td>${ts}</td> .filter(cb=>cb.checked).map(cb=>cb.value);
const labels = filtered.map(r=>r.ts);
const datasets = [];
if (selected.includes('temperature'))
datasets.push({ label:'Temperature (°F)', data:filtered.map(r=>r.temperature), tension:0.3 });
if (selected.includes('humidity'))
datasets.push({ label:'Humidity (%)', data:filtered.map(r=>r.humidity), tension:0.3 });
if (selected.includes('heatIndex'))
datasets.push({ label:'Heat Index (°F)', data:filtered.map(r=>r.heatIndex), tension:0.3 });
chart.data.labels = labels;
chart.data.datasets = datasets;
chart.update();
const tbody = document.getElementById('trends-table-body');
tbody.innerHTML = '';
filtered.forEach(r=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.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(2)}</td>
<td>${r.dockDoor}</td> <td>${r.stationDockDoor}</td>
<td>${dir}</td> <td>${r.location}</td>
</tr> `;
`); tbody.appendChild(tr);
}); });
// initialize or redraw DataTable
if ($.fn.DataTable.isDataTable('#trendTable')) {
dataTable.clear().rows.add($('#trendTable tbody tr')).draw();
} else {
dataTable = $('#trendTable').DataTable({
paging: true,
pageLength: 25,
ordering: true,
order: [[0,'desc']],
autoWidth: false,
scrollX: true
});
}
} }
// draw or update chart
async function drawTrend() {
const all = await fetchReadings();
const slider = document.getElementById('periodSlider');
const periodKey = periodKeys[slider.value];
const cfg = periodConfig[periodKey];
// group & stats
const groups = {};
all.forEach(r => {
const key = cfg.keyFn(r);
groups[key] = groups[key] || { temps:[], hums:[], his:[] };
groups[key].temps.push(r.temperature);
groups[key].hums.push(r.humidity);
groups[key].his.push(r.heatIndex);
});
const labels = Object.keys(groups).sort();
const stats = labels.map(k => {
const g = groups[k];
return {
temp: { avg: average(g.temps).toFixed(2), min: Math.min(...g.temps), max: Math.max(...g.temps) },
hum: { avg: average(g.hums).toFixed(2), min: Math.min(...g.hums), max: Math.max(...g.hums) },
hi: { avg: average(g.his).toFixed(2), min: Math.min(...g.his), max: Math.max(...g.his) }
};
});
// selected toggles
const checks = Array.from(document.querySelectorAll('#metricToggles input:checked'));
const datasets = checks.map(chk => {
const m = chk.dataset.metric, s = chk.dataset.stat;
return {
label: `${m.toUpperCase()} ${s.toUpperCase()}`,
data: stats.map(x => x[m][s]),
fill: false,
tension: 0.1
};
});
const ctx = document.getElementById('trendChart').getContext('2d');
if (trendChart) {
trendChart.data.labels = labels.map(cfg.labelFn);
trendChart.data.datasets = datasets;
trendChart.update();
} else {
trendChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels.map(cfg.labelFn),
datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true },
tooltip: { mode:'index', intersect:false },
zoom: {
pan: { enabled:true, mode:'x', modifierKey:'ctrl' },
zoom: { wheel:{enabled:true}, pinch:{enabled:true}, mode:'x' }
}
},
scales: {
x: { display:true },
y: { display:true }
}
}
});
}
// always update table below
renderTable(all);
}
// wire up controls
document.addEventListener('DOMContentLoaded', () => {
// initial draw
drawTrend();
// slider
document.getElementById('periodSlider')
.addEventListener('input', e => {
document.getElementById('periodLabel').textContent = periodLabels[e.target.value];
drawTrend();
});
// toggles
document.querySelectorAll('#metricToggles input')
.forEach(chk => chk.addEventListener('change', drawTrend));
});

View File

@ -1,59 +1,25 @@
/* ================= CSS VARIABLES ================= */ /* Favicon support (no CSS needed) */
:root {
--square-size: 60px;
--gap: 8px;
--v-gap: 24px;
--warehouse-height: 200px;
}
/* ================= GLOBAL & LAYOUT ================= */ /* BINS Project Header & Buttons */
html, body {
height: 100%;
margin: 0;
padding: 0;
background: #f4f4f4;
font-family: Arial, sans-serif;
}
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;
display: flex;
flex-direction: column;
min-height: calc(100vh - 70px);
}
/* ================= BINS HEADER & NAV BUTTONS ================= */
.main-header { .main-header {
background-color: #232F3E; background-color: #232F3E;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.5rem 2rem; padding: 0.5rem 1rem;
position: fixed; position: fixed; top: 0; left: 0; right: 0;
top: 0; left: 0; right: 0; width: 100%; z-index: 1000;
z-index: 1000; }
} .main-header .logo {
.logo {
height: 40px; height: 40px;
margin-right: 1rem; margin-right: 1rem;
} }
.header-title { .main-header .header-title {
color: #fff; color: #fff;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: bold; font-weight: bold;
margin-right: auto; margin-right: auto;
} }
.nav-btn { .main-header .nav-btn {
background-color: #FF9900; background-color: #FF9900;
color: #111; color: #111;
border: none; border: none;
@ -62,154 +28,36 @@ h1 {
margin-left: 0.5rem; margin-left: 0.5rem;
cursor: pointer; cursor: pointer;
font-weight: bold; font-weight: bold;
} }
.nav-btn:hover { .main-header .nav-btn:hover {
background-color: #e48f00; background-color: #e48f00;
} }
.page-container {
/* ================= FORM STYLES ================= */ margin-top: 70px;
.form-input { width: 95%; margin: 0 auto;
width: 80%;
max-width: 400px;
margin: 1rem auto;
display: block;
padding: 1rem; padding: 1rem;
font-size: 1rem; background-color: #fff;
border: 1px solid #ccc;
border-radius: 6px; border-radius: 6px;
} }
.big-button { .form-input, .form-textarea {
background-color: #28a745; width: 90%; margin: 0.5rem auto; display: block;
color: white; padding: 0.5rem; border: 1px solid #ccc; border-radius: 6px;
padding: 0.75rem 1.5rem; }
border: none; .big-button {
border-radius: 6px; background-color: #28a745; color: white;
cursor: pointer; padding: 0.75rem 1.5rem; border: none; border-radius: 6px;
font-size: 1rem; cursor: pointer; font-size: 1rem;
margin: 1rem auto; margin: 1rem auto; display: block;
display: block; }
} .big-button:hover { background-color: #218838; }
.big-button:hover { .hidden { display: none; }
background-color: #218838;
}
fieldset {
border: 1px solid #ccc;
border-radius: 6px;
padding: 1rem;
margin-bottom: 1.5rem;
}
legend {
font-weight: bold;
}
/* ================= TRENDS PAGE ================= */ /* Diagram styling */
/* Controls */ .diagram-container { width: 95%; margin: 0 auto; text-align: center; }
#trend-controls { .dock-row { display: grid; grid-template-columns: repeat(83, 1fr); gap: 4px; margin-bottom: 8px; }
flex: 0 0 auto; .dock-square { width: 100%; padding-top: 100%; position: relative; background: #ddd; border-radius: 2px; }
text-align: center; .dock-square::after { content: attr(data-door); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.6); color: #333; }
margin-bottom: 1rem; .warehouse { width: 95%; height: 200px; margin: 0 auto; background: #ccc; border-radius: 6px; }
}
/* Chart */
.chart-container {
flex: 1 1 auto;
width: 100%;
position: relative;
}
.chart-container canvas {
width: 100% !important;
height: 100% !important;
}
/* Table */
.table-container {
margin-top: 2rem;
overflow-x: auto;
}
table.dataTable {
width: 100% !important;
}
/* ================= HEATMAP STYLES ================= */ /* Existing element styles */
/* Scroll & pan wrapper */ body { font-family: Arial, sans-serif; margin: 20px; }
.heatmap-wrapper {
width: 100%;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
display: flex;
justify-content: flex-start;
align-items: center;
}
/* Entire diagram */
.diagram-container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--v-gap);
}
/* Dock-door row */
.dock-row {
display: flex;
gap: var(--gap);
}
/* Each dock square */
.dock-square {
width: var(--square-size);
height: var(--square-size);
background: #ddd;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #333;
cursor: pointer;
user-select: none;
}
/* Regions below dock row */
.regions-container {
display: flex;
width: 100%;
gap: var(--gap);
}
.region {
flex: 1;
height: 120px;
border: 2px solid #666;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
user-select: none;
}
.region-amod { background: #eef; }
.region-preslam { background: #efe; }
.region-bmod { background: #fee; }
/* Warehouse rectangle */
.warehouse {
width: calc(
(var(--square-size) * 83) + /* adjust door count if needed */
(var(--gap) * 82)
);
height: var(--warehouse-height);
background: #ccc;
border-radius: 6px;
}
/* Hover tooltip */
.tooltip {
position: absolute;
pointer-events: none;
background: rgba(0,0,0,0.75);
color: #fff;
padding: 8px;
border-radius: 4px;
font-size: 0.9rem;
line-height: 1.2;
display: none;
z-index: 2000;
}
/* ================= UTILITY ================= */
.hidden {
display: none;
}

View File

@ -10,15 +10,15 @@
<title>Trend Graph & Table</title> <title>Trend Graph & Table</title>
</head> </head>
<body> <body>
<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>
<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>
</header> </header>
<div class="page-container"> <div class="page-container">
<h1>Trend Analysis</h1> <h1>Trend Analysis</h1>
<div id="trend-controls" style="text-align:center; margin-bottom:1rem;"> <div id="trend-controls" style="text-align:center; margin-bottom:1rem;">
@ -64,15 +64,15 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<!-- Chart.js and zoom plugin --> <!-- Chart.js and zoom plugin -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
<!-- jQuery + DataTables --> <!-- jQuery + DataTables -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script> <script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
<!-- Your trends logic --> <!-- Your trends logic -->
<script src="scripts/trends.js"></script> <script src="scripts/trends.js"></script>
</body> </body>
</html> </html>

30
s3.js
View File

@ -1,30 +0,0 @@
// s3.js
require('dotenv').config();
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { stringify } = require('csv-stringify/sync');
const s3 = new S3Client({
region: process.env.AWS_REGION
});
/**
* Uploads a CSV to s3://<bucket>/trends/<key>.csv with public-read ACL.
* @param {string} key Date key like '20250423'
* @param {Array<Object>} rows Array of DB rows to stringify into CSV
* @returns {string} Public URL of the uploaded CSV
*/
async function uploadTrendsCsv(key, rows) {
// Convert rows to CSV string (includes header)
const csv = stringify(rows, { header: true });
const cmd = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME,
Key: `trends/${key}.csv`,
Body: csv,
ContentType: 'text/csv',
ACL: 'public-read'
});
await s3.send(cmd);
return `${process.env.S3_BASE_URL}/${key}.csv`;
}
module.exports = { uploadTrendsCsv };

408
server.js
View File

@ -13,111 +13,116 @@ const PORT = process.env.PORT || 3000;
// In-memory shift counters // In-memory shift counters
const shiftCounters = {}; const shiftCounters = {};
/** Helpers **/ // ─── Helpers ──────────────────────────────────────────────────────────────────
// pad to two digits
// Format a JS Date in EST as SQL DATETIME function pad2(n) {
function formatDateEST(date) { return n.toString().padStart(2, '0');
const pad = n => n.toString().padStart(2,'0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
} }
// Format a JS Date in EST as an ISO-like string (no Z) // Format epoch_ms → "M/D/YY @HH:mm" (24-hour) in America/New_York
function isoStringEST(date) { function formatForSlack(epoch) {
const pad = n => n.toString().padStart(2,'0'); return new Date(epoch).toLocaleString('en-US', {
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}` + timeZone: 'America/New_York',
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; month: 'numeric',
day: 'numeric',
year: '2-digit',
hour12: false,
hour: '2-digit',
minute: '2-digit'
}).replace(',', ' @');
} }
// Compute heat index (NOAA formula) // NOAA heat-index formula
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,
-0.00683783,-0.05481717,0.00122874,0.00085282,-0.00000199]; -0.00683783,-0.05481717,0.00122874,0.00085282,-0.00000199
];
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 Day/Night shift & period key from epoch_ms
function getShiftInfo(now) { function getShiftInfo(epoch) {
const estNow = new Date(now.toLocaleString('en-US',{ timeZone:'America/New_York' })); // Convert to EST by string-round-trip
const [h,m] = [estNow.getHours(), estNow.getMinutes()]; const estString = new Date(epoch)
let shift, shiftStart = new Date(estNow); .toLocaleString('en-US', { timeZone: 'America/New_York' });
const est = new Date(estString);
const h = est.getHours(), m = est.getMinutes();
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, key, estNow: est };
return { shift, shiftStart, key, estNow };
} }
// Fetch current weather from OpenWeatherMap // Fetch current Baltimore weather
async function fetchCurrentWeather() { async function fetchCurrentWeather() {
const apiKey = process.env.WEATHER_API_KEY; const key = process.env.WEATHER_API_KEY, zip = process.env.ZIP_CODE;
const zip = process.env.ZIP_CODE; if (!key || !zip) return 'Unavailable';
if (!apiKey || !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.replace(/^\w/,c=>c.toUpperCase());
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}. Hi of ${hi}, Humidity ${hum}%`;
}; } catch (e) {
} catch (err) { console.error('Weather API error:', e.message);
console.error('Weather API error:', err.message); return 'Unavailable';
return null;
} }
} }
/** MariaDB pool **/ // ─── MariaDB Pool & Table Setup ───────────────────────────────────────────────
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) (async () => {
(async ()=>{ // Create table with epoch_ms only
const createSQL = ` await pool.execute(`
CREATE TABLE IF NOT EXISTS readings ( CREATE TABLE IF NOT EXISTS readings (
id INT AUTO_INCREMENT PRIMARY KEY, id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
dockDoor INT, location VARCHAR(20) NOT NULL,
direction VARCHAR(10), stationDockDoor VARCHAR(10) NOT NULL,
region VARCHAR(20), epoch_ms BIGINT NOT NULL,
stationCode VARCHAR(10),
timestamp DATETIME NOT NULL,
temperature DOUBLE, temperature DOUBLE,
humidity DOUBLE, humidity DOUBLE,
heatIndex DOUBLE heatIndex DOUBLE,
); INDEX idx_time (epoch_ms),
`; INDEX idx_loc (location)
await pool.execute(createSQL); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
})(); })();
/** SSE for real-time updates **/ // ─── Middleware & Static ─────────────────────────────────────────────────────
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname,'public'), { index:'heatmap.html' }));
// ─── SSE Setup ────────────────────────────────────────────────────────────────
let clients = []; let clients = [];
app.get('/api/stream',(req,res)=>{ app.get('/api/stream',(req,res)=>{
res.set({ res.set({
@ -127,183 +132,182 @@ 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(event,data){
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
clients.forEach(c=>c.write(msg)); clients.forEach(c=>c.write(msg));
} }
// Middleware & static // ─── Dual Dock-Door Endpoint ───────────────────────────────────────────────────
app.use(bodyParser.json()); app.post('/api/readings', async (req,res) => {
app.use(express.static(path.join(__dirname,'public')));
/** Dual-dock-door readings **/
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 hiOut = computeHeatIndex(outTemp,outHum);
const { shift, shiftStart, key, estNow } = getShiftInfo(new Date());
shiftCounters[key]=(shiftCounters[key]||0)+1;
const period = shiftCounters[key];
const sqlTs = formatDateEST(estNow);
const bcTs = isoStringEST(estNow);
// Insert dock readings
const insertSQL = `
INSERT INTO readings
(dockDoor,direction,timestamp,temperature,humidity,heatIndex)
VALUES (?,?,?,?,?,?)
`;
await pool.execute(insertSQL,[inDoor,'inbound',sqlTs,inTemp,inHum,hiIn]);
await pool.execute(insertSQL,[outDoor,'outbound',sqlTs,outTemp,outHum,hiOut]);
// Broadcast SSE
broadcast('new-reading',{ dockDoor:inDoor, direction:'inbound',timestamp:bcTs,
temperature:inTemp, humidity:inHum, heatIndex:hiIn });
broadcast('new-reading',{ dockDoor:outDoor,direction:'outbound',timestamp:bcTs,
temperature:outTemp,humidity:outHum,heatIndex:hiOut });
// Build CSV & upload
const dateKey = `${estNow.getFullYear()}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`;
const [rows] = await pool.execute(
`SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp ASC`
);
let csvUrl=null;
try{ csvUrl = await uploadTrendsCsv(dateKey,rows); }
catch(e){ console.error('CSV upload error:',e); }
// Weather
const weather = await fetchCurrentWeather();
// Slack payload
const slackPayload = {
text: 'New temperature readings recorded',
inbound_dockDoor: inDoor,
inbound_temperature: inTemp,
inbound_humidity: inHum,
hiIn,
outbound_dockDoor: outDoor,
outbound_temperature:outTemp,
outbound_humidity: outHum,
hiOut,
shift,
period,
timestamp: bcTs,
csvUrl,
current_weather: weather?.description || null,
current_humidity: weather?.humidity || null,
current_temperature: weather?.temperature || null
};
await axios.post(process.env.SLACK_WEBHOOK_URL,slackPayload);
res.json({ success:true, shift,period,csvUrl });
} catch(err) {
console.error('POST /api/readings error:',err);
res.status(500).json({ error: err.message });
} }
});
/** Area (A-Mod / AFE-1 / AFE-2 / B-Mod) readings **/ const epoch = Date.now();
app.post('/api/area-readings',async(req,res)=>{ const hiIn = computeHeatIndex(inT, inH);
try { const hiOut = computeHeatIndex(outT, outH);
const { area, stationCode, temperature, humidity } = req.body; const { shift, key, estNow } = getShiftInfo(epoch);
if (!area||!stationCode||temperature==null||humidity==null) shiftCounters[key] = (shiftCounters[key]||0) + 1;
return res.status(400).json({error:'Missing area,stationCode,temp,humidity'});
const hi = computeHeatIndex(temperature,humidity);
const { shift, shiftStart, key, estNow } = getShiftInfo(new Date());
shiftCounters[key]=(shiftCounters[key]||0)+1;
const period = shiftCounters[key]; const period = shiftCounters[key];
const sqlTs = formatDateEST(estNow); const slackTs = formatForSlack(epoch);
const bcTs = isoStringEST(estNow);
// Insert area reading // Insert inbound + outbound
const sql = ` const sql = `
INSERT INTO readings INSERT INTO readings(location,stationDockDoor,epoch_ms,temperature,humidity,heatIndex)
(region,stationCode,timestamp,temperature,humidity,heatIndex) VALUES(?,?,?,?,?,?)
VALUES (?,?,?,?,?,?)
`; `;
await pool.execute(sql,[area,stationCode,sqlTs,temperature,humidity,hi]); await pool.execute(sql, ['Inbound', String(inD), epoch, inT, inH, hiIn]);
await pool.execute(sql, ['Outbound', String(outD), epoch, outT, outH, hiOut]);
// Broadcast SSE // SSE broadcast
broadcast('new-area-reading',{ region:area,stationCode,timestamp:bcTs, broadcast('new-reading', {
temperature,humidity,heatIndex:hi }); location: 'Inbound',
stationDockDoor: String(inD),
timestamp: slackTs,
temperature: inT,
humidity: inH,
heatIndex: hiIn
});
broadcast('new-reading', {
location: 'Outbound',
stationDockDoor: String(outD),
timestamp: slackTs,
temperature: outT,
humidity: outH,
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(FROM_UNIXTIME(epoch_ms/1000))=CURDATE()
ORDER BY epoch_ms
`);
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 // Slack message
const weather = await fetchCurrentWeather(); const weather = await fetchCurrentWeather();
const text =
`*_${shift} shift Period ${period} dock/trailer temperature checks for ${slackTs}_*\n`+
`*_Current Weather Forecast for Baltimore: ${weather}_*\n\n`+
`*_⬇ Inbound Dock Door 🚛 :_* ${inD}\n`+
`*_Temp:_* ${inT} °F 🌡️\n`+
`*_Humidity:_* ${inH} % 💦\n`+
`*_Heat Index:_* ${hiIn} °F 🥵\n\n`+
`*_⬆ Outbound Dock Door 🚛 :_* ${outD}\n`+
`*_Temp:_* ${outT} °F 🌡️\n`+
`*_Humidity:_* ${outH} % 💦\n`+
`*_Heat Index:_* ${hiOut} °F 🥵`;
// Slack payload await axios.post(process.env.SLACK_WEBHOOK_URL, { text }, {
const slackPayload = { headers:{ 'Content-Type':'application/json' }
text: 'New area temperature reading', });
area,
stationCode,
temperature,
humidity,
heatIndex: hi,
shift,
period,
timestamp: bcTs,
csvUrl,
current_weather: weather?.description || null,
current_humidity: weather?.humidity || null,
current_temperature: weather?.temperature || null
};
await axios.post(process.env.SLACK_WEBHOOK_URL,slackPayload);
res.json({ success:true, shift,period,csvUrl }); res.json({ success:true, shift, period, csvUrl });
} catch(err) { } catch (err) {
console.error('POST /api/area-readings error:',err); console.error('POST /api/readings error:', err);
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
/** Return all readings **/ // ─── Area/Mod Endpoint ───────────────────────────────────────────────────────
app.get('/api/readings',async(req,res)=>{ app.post('/api/area-readings', async (req,res) => {
try { try {
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`); const { area, stationCode, temperature:T, humidity:H } = req.body;
if (!area||!stationCode||T==null||H==null) {
return res.status(400).json({ error:'Missing fields' });
}
const epoch = Date.now();
const hi = computeHeatIndex(T, H);
const { shift, key, estNow } = getShiftInfo(epoch);
const slackTs = formatForSlack(epoch);
await pool.execute(`
INSERT INTO readings(location,stationDockDoor,epoch_ms,temperature,humidity,heatIndex)
VALUES(?,?,?,?,?,?)
`, [area, stationCode, epoch, T, H, hi]);
broadcast('new-area-reading', {
location: area,
stationDockDoor: stationCode,
timestamp: slackTs,
temperature: T,
humidity: H,
heatIndex: hi
});
const y=estNow.getFullYear(), m=pad2(estNow.getMonth()+1), d=pad2(estNow.getDate());
const dateKey=`${y}${m}${d}`;
const [rows] = await pool.execute(`
SELECT * FROM readings
WHERE DATE(FROM_UNIXTIME(epoch_ms/1000))=CURDATE()
ORDER BY epoch_ms
`);
let csvUrl=null;
try { csvUrl = await uploadTrendsCsv(dateKey, rows); }
catch(e){ console.error('CSV upload error:', e); }
const weather = await fetchCurrentWeather();
const text =
`*_${shift} shift ${area} temp check for ${slackTs}_*\n`+
`*_Current Weather Forecast for Baltimore: ${weather}_*\n\n`+
`*_${area.toUpperCase()} station:_* ${stationCode}\n`+
`*_Temp:_* ${T} °F 🌡️\n`+
`*_Humidity:_* ${H} % 💦\n`+
`*_Heat Index:_* ${hi} °F 🥵`;
await axios.post(process.env.SLACK_WEBHOOK_URL, { text }, {
headers:{ 'Content-Type':'application/json' }
});
res.json({ success:true, csvUrl });
} catch (err) {
console.error('POST /api/area-readings error:', err);
res.status(500).json({ error: err.message });
}
});
// ─── Fetch & Export ─────────────────────────────────────────────────────────
app.get('/api/readings', async (req,res) => {
try {
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY epoch_ms`);
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 });
} }
}); });
app.get('/api/export', async (req,res) => {
/** Export CSV of all readings **/
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 epoch_ms`);
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,epoch_ms,temperature,humidity,heatIndex\n');
rows.forEach(r=>{ rows.forEach(r => {
const ts = r.timestamp instanceof Date ? formatDateEST(r.timestamp) : r.timestamp; res.write(
res.write(`${r.id},${r.dockDoor||''},${r.direction||''},${r.region||''},${r.stationCode||''},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`); `${r.id},${r.location},${r.stationDockDoor},${r.epoch_ms},` +
`${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}`);
}); });