Heat-Tracker/server.js
2025-04-30 02:58:54 +00:00

329 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;
// Inmemory shift counters
const shiftCounters = {};
// ─── Helpers ─────────────────────────────────────────────────────────────────
// pad two digits
function pad2(n) {
return n.toString().padStart(2, '0');
}
// Format an epochms timestamp for Slack: "M/D/YY @HH:mm" in America/New_York
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(',', ' @');
}
// Compute NOAA heatindex
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 Day/Night shift & rolling period key based on a JS epoch
function getShiftInfo(epoch) {
// convert epoch to an EST Date object by roundtrip through toLocaleString
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, 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 epoch_ms instead of timestamp
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];
// Insert inbound and outbound
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]);
// Broadcast via SSE
const slackTs = formatForSlack(epoch);
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 today's CSV
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);
}
// Send 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);
await pool.execute(
`INSERT INTO readings(location,stationDockDoor,epoch_ms,temperature,humidity,heatIndex)
VALUES (?, ?, ?, ?, ?, ?)`,
[area, stationCode, epoch, T, H, hi]
);
const slackTs = formatForSlack(epoch);
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 Readings ────────────────────────────────────────────────────────
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 });
}
});
// ─── Export CSV ───────────────────────────────────────────────────────────────
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}`);
});