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

295 lines
11 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;
// ─── Helpers ────────────────────────────────────────────────────────────────
// zero-pad
const pad2 = n => n.toString().padStart(2,'0');
// Build an SQLcompatible DATETIME string in America/New_York (with DST)
function localDatetimeSQL(date) {
// datePart = "YYYY-MM-DD"
const datePart = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(date);
// timePart = "HH:MM:SS"
const timePart = new Intl.DateTimeFormat('en-GB', {
timeZone: 'America/New_York',
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date);
return `${datePart} ${timePart}`;
}
// Format a Date in EST as “M/D/YY @HH:mm” (24-hour, for Slack)
function shortEST(date) {
const dateFmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
month: 'numeric',
day: 'numeric',
year: '2-digit'
});
const timeFmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
hour12: false,
hour: '2-digit',
minute: '2-digit'
});
return `${dateFmt.format(date)} @${timeFmt.format(date)}`;
}
// NOAA heat-index 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 Day/Night shift and rolling period
function getShiftInfo(now) {
const est = new Date(now.toLocaleString('en-US', { timeZone:'America/New_York' }));
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 Baltimore forecast
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 ─────────────────────────────────────────────────────────────
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,
dateStrings: ['DATETIME'] // ensure we get raw strings back
});
// Ensure table exists
(async () => {
const sql = `
CREATE TABLE IF NOT EXISTS readings (
id INT AUTO_INCREMENT PRIMARY KEY,
location VARCHAR(20) NOT NULL,
stationDockDoor VARCHAR(10) NOT NULL,
timestamp DATETIME NOT NULL,
temperature DOUBLE,
humidity DOUBLE,
heatIndex DOUBLE
);
`;
await pool.execute(sql);
})();
// ─── 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(evt,data){
const msg = `event: ${evt}\ndata: ${JSON.stringify(data)}\n\n`;
clients.forEach(c=>c.write(msg));
}
// ─── Dual Dock-Door 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' });
// compute
const hiIn = computeHeatIndex(inT,inH);
const hiOut = computeHeatIndex(outT,outH);
const now = new Date();
const { shift, key, estNow } = getShiftInfo(now);
shiftCounters[key] = (shiftCounters[key]||0)+1;
const period = shiftCounters[key];
// timestamps
const sqlTs = localDatetimeSQL(estNow);
const shortTs = shortEST(estNow);
// insert
const ins = `
INSERT INTO readings
(location,stationDockDoor,timestamp,temperature,humidity,heatIndex)
VALUES(?,?,?,?,?,?)`;
await pool.execute(ins, ['Inbound', String(inD), sqlTs, inT, inH, hiIn]);
await pool.execute(ins, ['Outbound', String(outD), sqlTs, outT, outH, hiOut]);
// SSE
broadcast('new-reading', { location:'Inbound', stationDockDoor:String(inD), timestamp:shortTs, temperature:inT, humidity:inH, heatIndex:hiIn });
broadcast('new-reading', { location:'Outbound', stationDockDoor:String(outD), timestamp:shortTs, temperature:outT, humidity:outH, heatIndex:hiOut });
// upload 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(timestamp)=CURDATE() ORDER BY timestamp`);
let csvUrl=null;
try{ csvUrl=await uploadTrendsCsv(dateKey,rows) }catch(e){console.error(e)}
// weather + Slack
const weather = await fetchCurrentWeather();
const text =
`*_${shift} shift Period ${period} dock/ trailer temperature checks for ${shortTs}_*\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(err);
res.status(500).json({ error: err.message });
}
});
// ─── Area/Mod 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 hi = computeHeatIndex(T,H);
const now = new Date();
const { shift, estNow } = getShiftInfo(now);
const sqlTs = localDatetimeSQL(estNow);
const shortTs = shortEST(estNow);
await pool.execute(
`INSERT INTO readings(location,stationDockDoor,timestamp,temperature,humidity,heatIndex)
VALUES(?,?,?,?,?,?)`,
[area, stationCode, sqlTs, T, H, hi]
);
broadcast('new-area-reading', { location:area, stationDockDoor:stationCode, timestamp:shortTs, 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(timestamp)=CURDATE() ORDER BY timestamp`);
let csvUrl=null;
try{ csvUrl=await uploadTrendsCsv(dateKey,rows) }catch(e){console.error(e)}
const weather = await fetchCurrentWeather();
const text =
`*_${shift} shift ${area} temp check for ${shortTs}_*\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(err);
res.status(500).json({ error: err.message });
}
});
// ─── Export & Fetch ──────────────────────────────────────────────────────────
app.get('/api/readings', async (req,res)=>{
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp`);
res.json(rows);
});
app.get('/api/export', async (req,res)=>{
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp`);
res.setHeader('Content-disposition','attachment; filename=readings.csv');
res.set('Content-Type','text/csv');
res.write('id,location,stationDockDoor,timestamp,temperature,humidity,heatIndex\n');
rows.forEach(r=>{
// r.timestamp is already "YYYY-MM-DD HH:mm:ss" in EST/EDT
res.write(`${r.id},${r.location},${r.stationDockDoor},${r.timestamp},${r.temperature},${r.humidity},${r.heatIndex}\n`);
});
res.end();
});
// ─── Start ───────────────────────────────────────────────────────────────────
app.listen(PORT, ()=>{
console.log(`Server running on http://localhost:${PORT}`);
});