Heat-Tracker/server.js
2025-04-30 05:02:09 +00:00

331 lines
12 KiB
JavaScript
Raw Permalink 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.

// server.js
require('dotenv').config();
const express = require('express');
const fs = require('fs');
const path = require('path');
const mysql = require('mysql2/promise');
const bodyParser = require('body-parser');
const axios = require('axios');
const { uploadTrendsCsv } = require('./s3');
const app = express();
const PORT = process.env.PORT || 3000;
// In-memory shift counters
const shiftCounters = {};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function pad2(n) {
return n.toString().padStart(2, '0');
}
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(',', ' @');
}
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;
}
function getShiftInfo(epoch) {
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 };
}
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 () => {
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;
`);
})();
// ─── Load trends.html template ────────────────────────────────────────────────
const trendsTemplate = fs.readFileSync(
path.join(__dirname, 'public', 'trends.html'),
'utf8'
);
// ─── Inject initial data into trends.html ────────────────────────────────────
app.get('/trends.html', async (req, res) => {
try {
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY epoch_ms`);
const injected = trendsTemplate.replace(
'<!--INITIAL_DATA-->',
`<script>
window.__INITIAL_READINGS__ = ${JSON.stringify(rows)};
</script>`
);
res.send(injected);
} catch (err) {
console.error('Error rendering /trends.html:', err);
res.status(500).send('Server error');
}
});
// ─── 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);
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('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
});
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 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 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 & Export Endpoints ─────────────────────────────────────────────────
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}`);
});