Heat-Tracker/server.js
2025-04-30 03:34:08 +00:00

316 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// server.js
require('dotenv').config();
const express = require('express');
const mysql = require('mysql2/promise');
const bodyParser = require('body-parser');
const path = require('path');
const axios = require('axios');
const { uploadTrendsCsv } = require('./s3');
const app = express();
const PORT = process.env.PORT || 3000;
// In-memory shift counters
const shiftCounters = {};
// ─── Helpers ──────────────────────────────────────────────────────────────────
// Pad to 2 digits
function pad2(n) { return n.toString().padStart(2, '0'); }
// Format epoch_ms → "M/D/YY @HH:mm" in America/New_York for Slack/SSE
function formatForSlack(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(',', ' @');
}
// NOAA heatindex 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 (Day/Night) and period key from epoch_ms
function getShiftInfo(epoch) {
// Convert epoch to EST Date by string-roundtrip
const estString = new Date(epoch)
.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 < 17 || (h === 17 && m < 30)) {
shift = 'Day'; start.setHours(7, 0, 0, 0);
} else {
shift = 'Night'; start.setHours(17, 30, 0, 0);
}
} else {
shift = 'Night';
start.setDate(start.getDate() - 1);
start.setHours(17, 30, 0, 0);
}
const key = `${shift}-${start.toISOString().slice(0,10)}-${start.getHours()}${start.getMinutes()}`;
return { shift, key, estNow: est };
}
// Fetch current weather forecast for Baltimore
async function fetchCurrentWeather() {
const key = process.env.WEATHER_API_KEY;
const zip = process.env.ZIP_CODE;
if (!key || !zip) return 'Unavailable';
try {
const { data } = await axios.get(
'https://api.openweathermap.org/data/2.5/weather', {
params: { zip: `${zip},us`, appid: key, units: 'imperial' }
}
);
const desc = data.weather[0].description.replace(/^\w/, c => c.toUpperCase());
const hi = Math.round(data.main.temp_max);
const hum = data.main.humidity;
return `${desc}. Hi of ${hi}, Humidity ${hum}%`;
} catch (e) {
console.error('Weather API error:', e.message);
return 'Unavailable';
}
}
// ─── MariaDB Pool & Table Setup ───────────────────────────────────────────────
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
});
(async () => {
// Create readings table with only epoch_ms (no timestamp column)
await pool.execute(`
CREATE TABLE IF NOT EXISTS readings (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
location VARCHAR(20) NOT NULL,
stationDockDoor VARCHAR(10) NOT NULL,
epoch_ms BIGINT NOT NULL,
temperature DOUBLE,
humidity DOUBLE,
heatIndex DOUBLE,
INDEX idx_time (epoch_ms),
INDEX idx_loc (location)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
})();
// ─── Middleware & Static ─────────────────────────────────────────────────────
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, 'public'), { index: 'heatmap.html' }));
// ─── SSE Setup ────────────────────────────────────────────────────────────────
let clients = [];
app.get('/api/stream', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
});
res.flushHeaders();
clients.push(res);
req.on('close', () => { clients = clients.filter(c => c !== res); });
});
function broadcast(event, data) {
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
clients.forEach(c => c.write(msg));
}
// ─── Dual Dock-Door Readings Endpoint ─────────────────────────────────────────
app.post('/api/readings', async (req, res) => {
try {
const { inbound = {}, outbound = {} } = req.body;
const { dockDoor: inD, temperature: inT, humidity: inH } = inbound;
const { dockDoor: outD, temperature: outT, humidity: outH } = outbound;
if ([inD, inT, inH, outD, outT, outH].some(v => v == null)) {
return res.status(400).json({ error: 'Missing fields' });
}
const epoch = Date.now();
const hiIn = computeHeatIndex(inT, inH);
const hiOut = computeHeatIndex(outT, outH);
const { shift, key, estNow } = getShiftInfo(epoch);
shiftCounters[key] = (shiftCounters[key] || 0) + 1;
const period = shiftCounters[key];
const slackTs = formatForSlack(epoch);
// Insert inbound/outbound into epoch_ms
const insertSQL = `
INSERT INTO readings(location,stationDockDoor,epoch_ms,temperature,humidity,heatIndex)
VALUES (?, ?, ?, ?, ?, ?)
`;
await pool.execute(insertSQL, ['Inbound', String(inD), epoch, inT, inH, hiIn]);
await pool.execute(insertSQL, ['Outbound', String(outD), epoch, outT, outH, hiOut]);
// SSE broadcast
broadcast('new-reading', {
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
});
// Upload CSV of todays readings
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); }
// Slack notification
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 🥵`;
await axios.post(
process.env.SLACK_WEBHOOK_URL,
{ text },
{ headers: { 'Content-Type': 'application/json' } }
);
res.json({ success: true, shift, period, csvUrl });
} catch (err) {
console.error('POST /api/readings error:', err);
res.status(500).json({ error: err.message });
}
});
// ─── Area/Mod Station Readings Endpoint ───────────────────────────────────────
app.post('/api/area-readings', async (req, res) => {
try {
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 All & Export ───────────────────────────────────────────────────────
app.get('/api/readings', async (req, res) => {
try {
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY epoch_ms`);
res.json(rows);
} catch (err) {
console.error('GET /api/readings error:', err);
res.status(500).json({ error: err.message });
}
});
app.get('/api/export', async (req, res) => {
try {
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY epoch_ms`);
res.setHeader('Content-disposition', 'attachment; filename=readings.csv');
res.set('Content-Type', 'text/csv');
res.write('id,location,stationDockDoor,epoch_ms,temperature,humidity,heatIndex\n');
rows.forEach(r => {
res.write(`${r.id},${r.location},${r.stationDockDoor},${r.epoch_ms},${r.temperature},${r.humidity},${r.heatIndex}\n`);
});
res.end();
} catch (err) {
console.error('GET /api/export error:', err);
res.status(500).send(err.message);
}
});
// ─── Start Server ────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});