This commit is contained in:
joshbaney 2025-04-23 07:30:14 -04:00
parent 3c26875e5e
commit 697eb80d9c
8 changed files with 385 additions and 186 deletions

13
.env
View File

@ -9,8 +9,13 @@ DB_USER=joshbaney
DB_PASSWORD=Ran0dal5! DB_PASSWORD=Ran0dal5!
DB_NAME=heatmap DB_NAME=heatmap
# Slack Incoming Webhook URL #AWS s3
SLACK_WEBHOOK_URL=https://hooks.slack.com/triggers/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 S3_BUCKET_URL=https://s3.amazonaws.com/bwi2temps/trends
AWS s3 # Slack & AWS creds
S3_BUCKET_URL=https://s3.amazonaws.com/yourbucket/trends SLACK_WEBHOOK_URL=https://hooks.slack.com/triggers/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
AWS_ACCESS_KEY_ID=ihya/m4CONlywOPCNER22oZrbOeCdJLxp3R4H3oF
AWS_SECRET_ACCESS_KEY=AKIAQ3EGSIYOYP4L37HH
AWS_REGION=us-east-2
S3_BUCKET_NAME=bwi2temps
S3_BASE_URL=https://s3.amazonaws.com/bwi2temps/

View File

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

View File

@ -1,32 +1,13 @@
# docker-compose.yaml
version: '3.8' version: '3.8'
services: services:
db: fuego-app:
image: mariadb:10.11
restart: always
environment:
MYSQL_ROOT_PASSWORD: example_root_password
MYSQL_DATABASE: heat_tracker
MYSQL_USER: heat_user
MYSQL_PASSWORD: StrongP@ssw0rd
volumes:
- db_data:/var/lib/mysql
app:
build: . build: .
restart: always env_file: .env # your DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SLACK_WEBHOOK_URL, etc.
ports: ports:
- "3000:3000" - "3000:3000"
env_file:
- .env
environment:
# override DB_HOST to point at our db service
DB_HOST: db
DB_PORT: 3306
depends_on:
- db
volumes: volumes:
- .:/app - .:/usr/src/app # live code reload; remove in prod if undesired
restart: unless-stopped
volumes: # no depends_on here since the DB lives elsewhere
db_data:

71
public/index.html Normal file
View File

@ -0,0 +1,71 @@
<!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

@ -36,4 +36,42 @@
</div> </div>
<script src="scripts/input.js"></script> <script src="scripts/input.js"></script>
</body> </body>
</html> </html>
<script>
// make sure this runs *after* your form is in the DOM
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('readingForm');
form.addEventListener('submit', async e => {
e.preventDefault();
// grab your values
const inbound_dockDoor = +document.querySelector('input[name="inbound_dockDoor"]').value;
const inbound_temperature = +document.querySelector('input[name="inbound_temperature"]').value;
const inbound_humidity = +document.querySelector('input[name="inbound_humidity"]').value;
const outbound_dockDoor = +document.querySelector('input[name="outbound_dockDoor"]').value;
const outbound_temperature = +document.querySelector('input[name="outbound_temperature"]').value;
const outbound_humidity = +document.querySelector('input[name="outbound_humidity"]').value;
const payload = {
inbound_dockDoor,
inbound_temperature,
inbound_humidity,
outbound_dockDoor,
outbound_temperature,
outbound_humidity
};
console.log('👉 Sending payload:', payload);
try {
const resp = await fetch('/api/readings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await resp.json();
console.log('✅ Server response:', data);
} catch (err) {
console.error('❌ Fetch error:', err);
}
});
});
</script>

View File

@ -3,9 +3,11 @@ function average(arr) {
return arr.reduce((a, b) => a + b, 0) / arr.length; return arr.reduce((a, b) => a + b, 0) / arr.length;
} }
// period definitions // interval mappings
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 },
@ -19,22 +21,62 @@ const periodConfig = {
}; };
// global Chart.js instance // global Chart.js instance
let trendChart; let trendChart, dataTable;
// fetch all readings // fetch 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() : [];
} }
// build or update chart // determine direction based on dock door #
function getDirection(dock) {
return ( (dock>=124 && dock<=138) || (dock>=202 && dock<=209) )
? 'Inbound'
: 'Outbound';
}
// render DataTable
function renderTable(allReadings) {
const tbody = $('#trendTable tbody').empty();
allReadings.forEach(r => {
const ts = new Date(r.timestamp).toLocaleString('en-US', { timeZone:'America/New_York' });
const dir = getDirection(r.dockDoor);
tbody.append(`
<tr>
<td>${ts}</td>
<td>${r.temperature.toFixed(1)}</td>
<td>${r.humidity.toFixed(1)}</td>
<td>${r.heatIndex.toFixed(1)}</td>
<td>${r.dockDoor}</td>
<td>${dir}</td>
</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() { 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 periodKey = periodKeys[slider.value];
const cfg = periodConfig[periodKey]; const cfg = periodConfig[periodKey];
// group and compute stats // group & stats
const groups = {}; const groups = {};
all.forEach(r => { all.forEach(r => {
const key = cfg.keyFn(r); const key = cfg.keyFn(r);
@ -74,10 +116,13 @@ async function drawTrend() {
} else { } else {
trendChart = new Chart(ctx, { trendChart = new Chart(ctx, {
type: 'line', type: 'line',
data: { labels: labels.map(cfg.labelFn), datasets }, data: {
labels: labels.map(cfg.labelFn),
datasets
},
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, // <<— ensure the canvas fills its container maintainAspectRatio: false,
plugins: { plugins: {
legend: { display: true }, legend: { display: true },
tooltip: { mode:'index', intersect:false }, tooltip: { mode:'index', intersect:false },
@ -93,17 +138,24 @@ async function drawTrend() {
} }
}); });
} }
// always update table below
renderTable(all);
} }
// wire up controls
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// initial draw
drawTrend(); drawTrend();
// slider // 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 // toggles
document.querySelectorAll('#metricToggles input') document.querySelectorAll('#metricToggles input')
.forEach(chk => chk.addEventListener('change', drawTrend)); .forEach(chk => chk.addEventListener('change', drawTrend));
}); });

View File

@ -1,15 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<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" />
<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>Trend Graph</title> <!-- DataTables CSS for table styling, sorting arrows, etc. -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css" />
<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>
@ -21,33 +23,56 @@
<div id="trend-controls" style="text-align:center; margin-bottom:1rem;"> <div id="trend-controls" style="text-align:center; margin-bottom:1rem;">
<!-- Interval slider --> <!-- Interval slider -->
<label for="periodSlider">Interval: <strong><span id="periodLabel">Daily</span></strong></label><br/> <label for="periodSlider">
<input type="range" id="periodSlider" min="0" max="4" step="1" value="1"> Interval: <strong><span id="periodLabel">Daily</span></strong>
</label><br/>
<input type="range" id="periodSlider" min="0" max="4" step="1" value="1" />
<!-- Metric/stat toggles --> <!-- Metric/stat toggles -->
<div id="metricToggles" style="margin-top:1rem;"> <div id="metricToggles" style="margin-top:1rem;">
<!-- temperature --> <label><input type="checkbox" data-metric="temp" data-stat="avg" checked /> Temp Avg</label>
<label><input type="checkbox" data-metric="temp" data-stat="avg" checked> Temp Avg</label> <label><input type="checkbox" data-metric="temp" data-stat="min" checked /> Temp Min</label>
<label><input type="checkbox" data-metric="temp" data-stat="min" checked> Temp Min</label> <label><input type="checkbox" data-metric="temp" data-stat="max" checked /> Temp Max</label>
<label><input type="checkbox" data-metric="temp" data-stat="max" checked> Temp Max</label> <label><input type="checkbox" data-metric="hum" data-stat="avg" checked /> Hum Avg</label>
<!-- humidity --> <label><input type="checkbox" data-metric="hum" data-stat="min" checked /> Hum Min</label>
<label><input type="checkbox" data-metric="hum" data-stat="avg" checked> Hum Avg</label> <label><input type="checkbox" data-metric="hum" data-stat="max" checked /> Hum Max</label>
<label><input type="checkbox" data-metric="hum" data-stat="min" checked> Hum Min</label> <label><input type="checkbox" data-metric="hi" data-stat="avg" checked /> HI Avg</label>
<label><input type="checkbox" data-metric="hum" data-stat="max" checked> Hum Max</label> <label><input type="checkbox" data-metric="hi" data-stat="min" checked /> HI Min</label>
<!-- heat index --> <label><input type="checkbox" data-metric="hi" data-stat="max" checked /> HI Max</label>
<label><input type="checkbox" data-metric="hi" data-stat="avg" checked> HI Avg</label>
<label><input type="checkbox" data-metric="hi" data-stat="min" checked> HI Min</label>
<label><input type="checkbox" data-metric="hi" data-stat="max" checked> HI Max</label>
</div> </div>
</div> </div>
<div class="chart-container"> <div class="chart-container">
<canvas id="trendChart"></canvas> <canvas id="trendChart"></canvas>
</div> </div>
<!-- Table container -->
<div class="table-container">
<table id="trendTable" class="display" style="width:100%">
<thead>
<tr>
<th>Date/Time</th>
<th>Temperature (°F)</th>
<th>Humidity (%)</th>
<th>Heat Index</th>
<th>Location</th>
<th>Direction</th>
</tr>
</thead>
<tbody>
<!-- populated dynamically -->
</tbody>
</table>
</div>
</div> </div>
<!-- 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 -->
<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>
<!-- Your trends logic -->
<script src="scripts/trends.js"></script> <script src="scripts/trends.js"></script>
</body> </body>
</html> </html>

267
server.js
View File

@ -1,32 +1,82 @@
// server.js // server.js
require('dotenv').config(); require('dotenv').config();
const express = require('express'); const express = require('express');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const path = require('path'); const path = require('path');
const axios = require('axios'); const axios = require('axios');
const { uploadTrendsCsv } = require('./s3'); const { uploadTrendsCsv } = require('./s3');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// Inmemory shift counters // In-memory shift counters
const shiftCounters = {}; const shiftCounters = {};
// Create MariaDB connection pool // Helper: format a Date in EST as SQL DATETIME string
// server.js (pool section) function formatDateEST(date) {
const pool = mysql.createPool({ const pad = n => n.toString().padStart(2, '0');
host: process.env.DB_HOST, return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ` +
port: parseInt(process.env.DB_PORT, 10) || 3306, `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
user: process.env.DB_USER, }
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
connectTimeout: 10000 // give it up to 10s before timing out
});
// Helper: format a Date in EST as ISO-like string (no “Z”)
function isoStringEST(date) {
const pad = n => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
// Compute heat index (NOAA formula)
function computeHeatIndex(T, R) {
const [c1,c2,c3,c4,c5,c6,c7,c8,c9] =
[-42.379,2.04901523,10.14333127,-0.22475541,-0.00683783,
-0.05481717,0.00122874,0.00085282,-0.00000199];
const HI = c1 + c2*T + c3*R + c4*T*R
+ c5*T*T + c6*R*R + c7*T*T*R
+ c8*T*R*R + c9*T*T*R*R;
return Math.round(HI * 100) / 100;
}
// Determine shift info in EST
function getShiftInfo(now) {
const estNow = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
const [h,m] = [estNow.getHours(), estNow.getMinutes()];
let shift, shiftStart = new Date(estNow);
if (h > 7 || (h === 7 && m >= 0)) {
if (h < 17 || (h === 17 && m < 30)) {
shift = 'Day';
shiftStart.setHours(7,0,0,0);
} else {
shift = 'Night';
shiftStart.setHours(17,30,0,0);
}
} else {
shift = 'Night';
shiftStart.setDate(shiftStart.getDate() - 1);
shiftStart.setHours(17,30,0,0);
}
const key = `${shift}-${shiftStart.toISOString().slice(0,10)}-` +
`${shiftStart.getHours()}${shiftStart.getMinutes()}`;
return { shift, shiftStart, key, estNow };
}
// MariaDB connection pool
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10) || 3306,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
connectTimeout: 10000,
enableKeepAlive: true,
keepAliveInitialDelay: 10000,
});
// Ensure readings table exists // Ensure readings table exists
(async () => { (async () => {
@ -34,6 +84,7 @@ const pool = mysql.createPool({
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 NOT NULL, dockDoor INT NOT NULL,
direction VARCHAR(10) NOT NULL,
timestamp DATETIME NOT NULL, timestamp DATETIME NOT NULL,
temperature DOUBLE, temperature DOUBLE,
humidity DOUBLE, humidity DOUBLE,
@ -43,147 +94,127 @@ const pool = mysql.createPool({
await pool.execute(createSQL); await pool.execute(createSQL);
})(); })();
// Simple SSE setup // SSE setup
let clients = []; let clients = [];
app.get('/api/stream', (req, res) => { app.get('/api/stream', (req, res) => {
res.set({ res.set({
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
Connection: 'keep-alive' Connection: 'keep-alive'
}); });
res.flushHeaders(); res.flushHeaders();
clients.push(res); clients.push(res);
req.on('close', () => { req.on('close', () => { clients = clients.filter(c => c !== res); });
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));
} }
// Compute heat index (NOAA formula)
function computeHeatIndex(T, R) {
const c1=-42.379, c2=2.04901523, c3=10.14333127;
const c4=-0.22475541, c5=-6.83783e-3, c6=-0.05481717;
const c7=0.00122874, c8=0.00085282, c9=-0.00000199;
const HI = c1 + c2*T + c3*R + c4*T*R
+ c5*T*T + c6*R*R + c7*T*T*R
+ c8*T*R*R + c9*T*T*R*R;
return Math.round(HI * 100) / 100;
}
// Determine shift info in EST
function getShiftInfo(now) {
// Convert to EST
const estNow = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
const h = estNow.getHours(), m = estNow.getMinutes();
let shift, shiftStart;
if (h > 7 || (h === 7 && m >= 0)) {
if (h < 17 || (h === 17 && m < 30)) {
shift = 'Day';
shiftStart = new Date(estNow);
shiftStart.setHours(7,0,0,0);
} else {
shift = 'Night';
shiftStart = new Date(estNow);
shiftStart.setHours(17,30,0,0);
}
} else {
shift = 'Night';
shiftStart = new Date(estNow);
shiftStart.setDate(shiftStart.getDate() - 1);
shiftStart.setHours(17,30,0,0);
}
const key = `${shift}-${shiftStart.toISOString().slice(0,10)}-${shiftStart.getHours()}${shiftStart.getMinutes()}`;
return { shift, shiftStart, key, estNow };
}
// Middleware & static files // Middleware & static files
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
// Dualreading POST endpoint // Dual-reading POST endpoint
app.post('/api/readings', async (req, res) => { app.post('/api/readings', async (req, res) => {
try { try {
const { inbound, outbound } = req.body; console.log('🔔 POST /api/readings body:', req.body);
const hiIn = computeHeatIndex(inbound.temperature, inbound.humidity); const { inbound = {}, outbound = {} } = req.body;
const hiOut = computeHeatIndex(outbound.temperature, outbound.humidity); const { dockDoor: inDoor, temperature: inTemp, humidity: inHum } = inbound;
const { dockDoor: outDoor, temperature: outTemp, humidity: outHum } = outbound;
// Validate inputs
if ([inDoor, inTemp, inHum, outDoor, outTemp, outHum].some(v => v === undefined)) {
return res.status(400).json({ error: 'Missing one of inbound/outbound fields' });
}
// Compute heat indices
const hiIn = computeHeatIndex(inTemp, inHum);
const hiOut = computeHeatIndex(outTemp, outHum);
// Shift & period logic // Shift & period logic
const { shift, shiftStart, key, estNow } = getShiftInfo(new Date()); const { shift, shiftStart, key, estNow } = getShiftInfo(new Date());
shiftCounters[key] = (shiftCounters[key] || 0) + 1; shiftCounters[key] = (shiftCounters[key] || 0) + 1;
const period = shiftCounters[key]; const period = shiftCounters[key];
// Insert both readings with serverside NOW() // Prepare EST timestamps
const sqlTimestamp = formatDateEST(estNow);
const broadcastTimestamp = isoStringEST(estNow);
// Insert readings into DB
const insertSQL = ` const insertSQL = `
INSERT INTO readings INSERT INTO readings
(dockDoor, timestamp, temperature, humidity, heatIndex) (dockDoor, direction, timestamp, temperature, humidity, heatIndex)
VALUES (?, NOW(), ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`; `;
await pool.execute(insertSQL, [inbound.dockDoor, inbound.temperature, inbound.humidity, hiIn]); await pool.execute(insertSQL, [inDoor, 'inbound', sqlTimestamp, inTemp, inHum, hiIn]);
await pool.execute(insertSQL, [outbound.dockDoor, outbound.temperature, outbound.humidity, hiOut]); await pool.execute(insertSQL, [outDoor, 'outbound', sqlTimestamp, outTemp, outHum, hiOut]);
// Broadcast to SSE clients (use UTC ISO for frontend) // Broadcast via SSE (EST)
const isoNow = new Date().toISOString(); broadcast('new-reading', {
broadcast('new-reading', { dockDoor: inbound.dockDoor, timestamp: isoNow, ...inbound, heatIndex: hiIn }); dockDoor: inDoor,
broadcast('new-reading', { dockDoor: outbound.dockDoor, timestamp: isoNow, ...outbound, heatIndex: hiOut }); direction: 'inbound',
timestamp: broadcastTimestamp,
// Format EST timestamp for Slack temperature: inTemp,
const estString = estNow.toLocaleString('en-US', { humidity: inHum,
timeZone: 'America/New_York', heatIndex: hiIn
hour12: false, });
year: 'numeric', month: '2-digit', day: '2-digit', broadcast('new-reading', {
hour: '2-digit', minute: '2-digit' dockDoor: outDoor,
direction: 'outbound',
timestamp: broadcastTimestamp,
temperature: outTemp,
humidity: outHum,
heatIndex: hiOut
}); });
// Base Slack text // Generate and upload today's CSV, get URL
let slackText = const dateKey = `${estNow.getFullYear()}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`;
`${shift} Shift ${period} temperature checks for ${estString} EST
Inbound Dock Door: ${inbound.dockDoor}
Temp: ${inbound.temperature}°F
Humidity: ${inbound.humidity}%
Heat Index: ${hiIn}
Outbound Dock Door: ${outbound.dockDoor}
Temp: ${outbound.temperature}°F
Humidity: ${outbound.humidity}%
Heat Index: ${hiOut}`;
// Upload today's CSV and append URL
const dateKey = `${String(estNow.getFullYear())}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`;
const [rows] = await pool.execute( const [rows] = await pool.execute(
`SELECT * FROM readings `SELECT * FROM readings
WHERE DATE(timestamp) = CURDATE() WHERE DATE(timestamp) = CURDATE()
ORDER BY timestamp ASC` ORDER BY timestamp ASC`
); );
let csvUrl = null;
try { try {
const publicUrl = await uploadTrendsCsv(dateKey, rows); csvUrl = await uploadTrendsCsv(dateKey, rows);
slackText += `\n\nDownload daily trends: ${publicUrl}`; } catch (uploadErr) {
} catch(uploadErr) {
console.error('Failed to upload CSV to S3:', uploadErr); console.error('Failed to upload CSV to S3:', uploadErr);
} }
// Send Slack notification // Flat JSON Slack payload with CSV URL
await axios.post(process.env.SLACK_WEBHOOK_URL, { text: slackText }); 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: broadcastTimestamp,
csvUrl // include the CSV download URL
};
console.log('🛠️ Slack payload:', slackPayload);
await axios.post(process.env.SLACK_WEBHOOK_URL, slackPayload);
res.json({ success: true, shift, period }); 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 });
} }
}); });
// Return all readings as JSON // Return 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 ASC`);
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 });
} }
}); });
@ -194,21 +225,19 @@ app.get('/api/export', async (req, res) => {
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`); const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
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,timestamp,temperature,humidity,heatIndex\n'); res.write('id,dockDoor,direction,timestamp,temperature,humidity,heatIndex\n');
rows.forEach(r => { rows.forEach(r => {
// ensure ISO timestamp const ts = (r.timestamp instanceof Date) ? formatDateEST(r.timestamp) : r.timestamp;
const ts = (r.timestamp instanceof Date) res.write(`${r.id},${r.dockDoor},${r.direction},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`);
? r.timestamp.toISOString()
: r.timestamp;
res.write(`${r.id},${r.dockDoor},${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
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://localhost:${PORT}`);
}); });