Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
0463f6ffaa | |||
ab3065980e | |||
2dd13c1467 | |||
8348d1a3fa | |||
ab5f5d2dfc | |||
![]() |
3c26875e5e | ||
![]() |
8738371bbb |
14
.env
14
.env
@ -1,18 +1,18 @@
|
||||
# Server port
|
||||
PORT=3000
|
||||
|
||||
WEATHER_API_KEY=27a2e8429bdc47104adb6572ef9f7ad9
|
||||
ZIP_CODE=21224
|
||||
|
||||
# MariaDB connection
|
||||
DB_CLIENT=mysql
|
||||
DB_HOST=172.23.0.3
|
||||
DB_PORT=3306
|
||||
DB_USER=joshbaney
|
||||
DB_HOST=0.0.0.0
|
||||
DB_PORT=3307
|
||||
DB_USER=fuego
|
||||
DB_PASSWORD=Ran0dal5!
|
||||
DB_NAME=heatmap
|
||||
|
||||
#AWS s3
|
||||
# Slack Incoming Webhook URL
|
||||
SLACK_WEBHOOK_URL=
|
||||
|
||||
AWS s3
|
||||
S3_BUCKET_URL=https://s3.amazonaws.com/bwi2temps/trends
|
||||
|
||||
# Slack & AWS creds
|
||||
|
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -0,0 +1,18 @@
|
||||
# Use official Node.js 18 LTS alpine image
|
||||
FROM node:18-alpine
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install app dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the server
|
||||
CMD ["npm", "start"]
|
32
docker-compose.yaml
Normal file
32
docker-compose.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mariadb:10.11
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: Ran0dal5!
|
||||
MYSQL_DATABASE: heatmap
|
||||
MYSQL_USER: fuego
|
||||
MYSQL_PASSWORD: Ran0dal5!2706
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
|
||||
app:
|
||||
build: .
|
||||
restart: always
|
||||
ports:
|
||||
- "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:
|
||||
- .:/app
|
||||
|
||||
volumes:
|
||||
db_data:
|
14
package.json
14
package.json
@ -1,14 +1,18 @@
|
||||
{
|
||||
"name": "warehouse-heatmap-app",
|
||||
"name": "Amazon-Fuego",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.300.0",
|
||||
"axios": "^1.4.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"csv-stringify": "^6.0.8",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"mysql2": "^3.2.0",
|
||||
"knex": "^2.4.2",
|
||||
"body-parser": "^1.20.2"
|
||||
}
|
||||
"mysql2": "^3.3.3",
|
||||
"sqlite3": "^5.1.6"
|
||||
}
|
||||
}
|
||||
|
@ -5,24 +5,35 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<title>Warehouse Heat Map</title>
|
||||
<title>Heat Map</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="main-header">
|
||||
<img src="/image/logo.png" alt="Amazon Logo" class="logo">
|
||||
<span class="header-title">| Fuego - Heat Tracker</span>
|
||||
|
||||
<!-- BINS‑style fixed header -->
|
||||
<header class="main-header">
|
||||
<img src="/image/logo.png" class="logo" alt="Amazon Logo">
|
||||
<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='/heatmap.html'">Heat Map</button>
|
||||
<button class="nav-btn" onclick="location.href='/trends.html'">Trends</button>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="diagram-container">
|
||||
<div class="page-container">
|
||||
<div class="heatmap-wrapper">
|
||||
<div id="diagram" class="diagram-container">
|
||||
<div id="dock-row" class="dock-row"></div>
|
||||
<div class="warehouse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tooltip" class="tooltip"></div>
|
||||
</div>
|
||||
|
||||
<script src="scripts/heatmap.js"></script>
|
||||
<script src="scripts/heatmap.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
<script src="scripts/heatmap.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
@ -8,15 +8,15 @@
|
||||
<title>Log Dual Readings</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="main-header">
|
||||
<header class="main-header">
|
||||
<img src="/image/logo.png" alt="Amazon Logo" class="logo">
|
||||
<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='/heatmap.html'">Heat Map</button>
|
||||
<button class="nav-btn" onclick="location.href='/trends.html'">Trends</button>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="page-container">
|
||||
<h1>Enter Inbound & Outbound Readings</h1>
|
||||
<form id="reading-form">
|
||||
<fieldset>
|
||||
@ -33,7 +33,7 @@
|
||||
</fieldset>
|
||||
<button type="submit" class="big-button">Submit Both</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="scripts/input.js"></script>
|
||||
</div>
|
||||
<script src="scripts/input.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,51 +1,129 @@
|
||||
// door ranges
|
||||
// dock‑door ranges
|
||||
const doors = [
|
||||
...Array.from({length: 138 - 124 + 1}, (_, i) => 124 + i),
|
||||
...Array.from({length: 201 - 142 + 1}, (_, i) => 142 + i),
|
||||
...Array.from({length: 209 - 202 + 1}, (_, i) => 202 + i),
|
||||
];
|
||||
];
|
||||
|
||||
// NOAA heat‐index formula
|
||||
function getColorFromHI(H) {
|
||||
// store readings per door
|
||||
const doorData = {};
|
||||
|
||||
// color by heat index
|
||||
function getColorFromHI(H) {
|
||||
const pct = Math.min(Math.max((H - 70) / 30, 0), 1);
|
||||
const r = 255;
|
||||
const g = Math.round(255 * (1 - pct));
|
||||
const r = 255, g = Math.round(255 * (1 - pct));
|
||||
return `rgba(${r},${g},0,0.8)`;
|
||||
}
|
||||
}
|
||||
|
||||
function createGrid() {
|
||||
// build the row of squares
|
||||
function createGrid() {
|
||||
const row = document.getElementById('dock-row');
|
||||
doors.forEach(d => {
|
||||
doorData[d] = [];
|
||||
const sq = document.createElement('div');
|
||||
sq.className = 'dock-square';
|
||||
sq.dataset.door = d;
|
||||
sq.textContent = d;
|
||||
row.appendChild(sq);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// apply a new reading to its square
|
||||
function colorize(door, hi) {
|
||||
// compute stats
|
||||
function computeStats(arr) {
|
||||
if (!arr.length) return null;
|
||||
let sum = 0, max = arr[0], min = arr[0];
|
||||
arr.forEach(r => {
|
||||
sum += r.heatIndex;
|
||||
if (r.heatIndex > max.heatIndex) max = r;
|
||||
if (r.heatIndex < min.heatIndex) min = r;
|
||||
});
|
||||
const avg = (sum / arr.length).toFixed(2);
|
||||
const latest = arr[arr.length - 1];
|
||||
return { latest, max, min, avg };
|
||||
}
|
||||
|
||||
// color a square
|
||||
function fillSquare(door, hi) {
|
||||
const sq = document.querySelector(`.dock-square[data-door="${door}"]`);
|
||||
if (!sq) return;
|
||||
sq.style.background = getColorFromHI(hi);
|
||||
}
|
||||
if (sq) sq.style.background = getColorFromHI(hi);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
createGrid();
|
||||
// initial fill
|
||||
const all = await fetch('/api/readings').then(r=>r.json());
|
||||
// pick latest per door
|
||||
const latest = {};
|
||||
all.forEach(r => { latest[r.dockDoor] = r.heatIndex; });
|
||||
Object.entries(latest).forEach(([door, hi]) => colorize(door, hi));
|
||||
// load initial readings
|
||||
async function loadInitial() {
|
||||
const all = await fetch('/api/readings').then(r => r.json());
|
||||
all.forEach(r => {
|
||||
if (doorData[r.dockDoor]) doorData[r.dockDoor].push(r);
|
||||
});
|
||||
Object.entries(doorData).forEach(([d, arr]) => {
|
||||
const stats = computeStats(arr);
|
||||
if (stats) fillSquare(d, stats.latest.heatIndex);
|
||||
});
|
||||
}
|
||||
|
||||
// subscribe SSE
|
||||
// real‑time updates
|
||||
function subscribeRealtime() {
|
||||
const es = new EventSource('/api/stream');
|
||||
es.addEventListener('new-reading', e => {
|
||||
const { dockDoor, heatIndex } = JSON.parse(e.data);
|
||||
colorize(dockDoor, heatIndex);
|
||||
});
|
||||
const r = JSON.parse(e.data);
|
||||
if (doorData[r.dockDoor]) {
|
||||
doorData[r.dockDoor].push(r);
|
||||
fillSquare(r.dockDoor, r.heatIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
// tooltips on hover
|
||||
function setupTooltips() {
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
document.querySelectorAll('.dock-square').forEach(sq => {
|
||||
sq.addEventListener('mouseenter', () => {
|
||||
const d = Number(sq.dataset.door);
|
||||
const stats = computeStats(doorData[d]);
|
||||
if (!stats) return;
|
||||
tooltip.innerHTML = `
|
||||
<strong>Door ${d}</strong><br/>
|
||||
Latest: HI ${stats.latest.heatIndex}, T ${stats.latest.temperature}°F, H ${stats.latest.humidity}%<br/>
|
||||
Time: ${new Date(stats.latest.timestamp).toLocaleString()}<br/>
|
||||
Max: ${stats.max.heatIndex} at ${new Date(stats.max.timestamp).toLocaleString()}<br/>
|
||||
Min: ${stats.min.heatIndex} at ${new Date(stats.min.timestamp).toLocaleString()}<br/>
|
||||
Avg HI: ${stats.avg}
|
||||
`;
|
||||
tooltip.style.display = 'block';
|
||||
});
|
||||
sq.addEventListener('mousemove', e => {
|
||||
tooltip.style.top = `${e.clientY + 10}px`;
|
||||
tooltip.style.left = `${e.clientX + 10}px`;
|
||||
});
|
||||
sq.addEventListener('mouseleave', () => {
|
||||
tooltip.style.display = 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// zoom via CSS vars
|
||||
function setupZoom() {
|
||||
const root = document.documentElement.style;
|
||||
let scale = 1;
|
||||
const initial = { size: 60, gap: 8, vgap: 24, wh: 200 };
|
||||
const wrapper = document.querySelector('.heatmap-wrapper');
|
||||
|
||||
wrapper.addEventListener('wheel', e => {
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
scale += e.deltaY * -0.001;
|
||||
scale = Math.min(Math.max(scale, 0.5), 3);
|
||||
root.setProperty('--square-size', `${initial.size * scale}px`);
|
||||
root.setProperty('--gap', `${initial.gap * scale}px`);
|
||||
root.setProperty('--vertical-gap', `${initial.vgap * scale}px`);
|
||||
root.setProperty('--warehouse-height', `${initial.wh * scale}px`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
createGrid();
|
||||
loadInitial();
|
||||
subscribeRealtime();
|
||||
setupTooltips();
|
||||
setupZoom();
|
||||
});
|
||||
|
@ -1,135 +1,161 @@
|
||||
// public/scripts/trends.js
|
||||
|
||||
// ─── Timeframes ──────────────────────────────────────────────────────────────
|
||||
const tfConfig = [
|
||||
{ unit:'hours', count:24, label:'Last 24 Hours' },
|
||||
{ unit:'days', count:7, label:'Last 7 Days' },
|
||||
{ unit:'weeks', count:4, label:'Last 4 Weeks' },
|
||||
{ unit:'months', count:12, label:'Last 12 Months' },
|
||||
{ unit:'years', count:1, label:'All Time' }
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function formatEST(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 subtract(date, count, unit) {
|
||||
const d = new Date(date);
|
||||
switch(unit){
|
||||
case 'hours': d.setHours(d.getHours() - count); break;
|
||||
case 'days': d.setDate(d.getDate() - count); break;
|
||||
case 'weeks': d.setDate(d.getDate() - 7*count); break;
|
||||
case 'months': d.setMonth(d.getMonth() - count); break;
|
||||
case 'years': d.setFullYear(d.getFullYear() - count); break;
|
||||
}
|
||||
return d;
|
||||
// average helper
|
||||
function average(arr) {
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
let readings = [];
|
||||
let chart;
|
||||
// interval mappings
|
||||
const periodKeys = ['hourly','daily','weekly','monthly','yearly'];
|
||||
const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly'];
|
||||
|
||||
// ─── On Load ─────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', async() => {
|
||||
const data = await fetch('/api/readings').then(r=>r.json());
|
||||
readings = data.map(r=>({
|
||||
...r,
|
||||
ts: formatEST(r.epoch_ms),
|
||||
date: new Date(r.epoch_ms)
|
||||
}));
|
||||
setupUI(); initChart(); updateView();
|
||||
});
|
||||
// bucket definitions
|
||||
const periodConfig = {
|
||||
hourly: { keyFn: r => r.timestamp.slice(0,13), labelFn: k => k.replace('T',' ') },
|
||||
daily: { keyFn: r => r.timestamp.slice(0,10), labelFn: k => k },
|
||||
weekly: { keyFn: r => {
|
||||
const d = new Date(r.timestamp), y = d.getFullYear();
|
||||
const w = Math.ceil((((d - new Date(y,0,1))/864e5) + new Date(y,0,1).getDay()+1)/7);
|
||||
return `${y}-W${w}`;
|
||||
}, labelFn: k => k },
|
||||
monthly: { keyFn: r => r.timestamp.slice(0,7), labelFn: k => k },
|
||||
yearly: { keyFn: r => r.timestamp.slice(0,4), labelFn: k => k }
|
||||
};
|
||||
|
||||
// ─── UI Wiring ────────────────────────────────────────────────────────────────
|
||||
function setupUI(){
|
||||
const slider = document.getElementById('timeframe-slider');
|
||||
const label = document.getElementById('timeframe-label');
|
||||
slider.addEventListener('input',()=>{
|
||||
label.textContent = tfConfig[slider.value].label;
|
||||
updateView();
|
||||
});
|
||||
label.textContent = tfConfig[slider.value].label;
|
||||
// global Chart.js instance
|
||||
let trendChart, dataTable;
|
||||
|
||||
document.getElementsByName('metric').forEach(cb=>cb.addEventListener('change',updateView));
|
||||
|
||||
document.querySelectorAll('#trends-table thead th.sortable').forEach(th=>{
|
||||
th.addEventListener('click', ()=>{
|
||||
const idx = th.cellIndex;
|
||||
const asc = !th.classList.contains('asc');
|
||||
const tbody = document.getElementById('trends-table-body');
|
||||
const rows = Array.from(tbody.rows);
|
||||
rows.sort((a,b)=>{
|
||||
return asc
|
||||
? a.cells[idx].textContent.localeCompare(b.cells[idx].textContent,undefined,{numeric:true})
|
||||
: b.cells[idx].textContent.localeCompare(a.cells[idx].textContent,undefined,{numeric:true});
|
||||
});
|
||||
th.classList.toggle('asc',asc);
|
||||
rows.forEach(r=>tbody.appendChild(r));
|
||||
});
|
||||
});
|
||||
// fetch readings from server
|
||||
async function fetchReadings() {
|
||||
const res = await fetch('/api/readings');
|
||||
return res.ok ? res.json() : [];
|
||||
}
|
||||
|
||||
// ─── Chart.js Setup ──────────────────────────────────────────────────────────
|
||||
function initChart(){
|
||||
const ctx = document.getElementById('trend-chart').getContext('2d');
|
||||
chart = new Chart(ctx,{
|
||||
type:'line',
|
||||
data:{labels:[],datasets:[]},
|
||||
options:{
|
||||
scales:{
|
||||
x:{ title:{display:true,text:'Time (EST)'} },
|
||||
y:{ title:{display:true,text:'Value'} }
|
||||
},
|
||||
interaction:{mode:'index',intersect:false},
|
||||
plugins:{legend:{position:'top'}},
|
||||
maintainAspectRatio:false
|
||||
}
|
||||
});
|
||||
// determine direction based on dock door #
|
||||
function getDirection(dock) {
|
||||
return ( (dock>=124 && dock<=138) || (dock>=202 && dock<=209) )
|
||||
? 'Inbound'
|
||||
: 'Outbound';
|
||||
}
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||
function updateView(){
|
||||
const idx = +document.getElementById('timeframe-slider').value;
|
||||
const {unit,count} = tfConfig[idx];
|
||||
const cutoff = subtract(Date.now(),count,unit);
|
||||
|
||||
const filtered = readings.filter(r=>r.date>=cutoff);
|
||||
const selected = Array.from(document.getElementsByName('metric'))
|
||||
.filter(cb=>cb.checked).map(cb=>cb.value);
|
||||
|
||||
const labels = filtered.map(r=>r.ts);
|
||||
const datasets = [];
|
||||
if (selected.includes('temperature'))
|
||||
datasets.push({ label:'Temperature (°F)', data:filtered.map(r=>r.temperature), tension:0.3 });
|
||||
if (selected.includes('humidity'))
|
||||
datasets.push({ label:'Humidity (%)', data:filtered.map(r=>r.humidity), tension:0.3 });
|
||||
if (selected.includes('heatIndex'))
|
||||
datasets.push({ label:'Heat Index (°F)', data:filtered.map(r=>r.heatIndex), tension:0.3 });
|
||||
|
||||
chart.data.labels = labels;
|
||||
chart.data.datasets = datasets;
|
||||
chart.update();
|
||||
|
||||
const tbody = document.getElementById('trends-table-body');
|
||||
tbody.innerHTML = '';
|
||||
filtered.forEach(r=>{
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${r.ts}</td>
|
||||
// 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(2)}</td>
|
||||
<td>${r.stationDockDoor}</td>
|
||||
<td>${r.location}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
<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() {
|
||||
const all = await fetchReadings();
|
||||
const slider = document.getElementById('periodSlider');
|
||||
const periodKey = periodKeys[slider.value];
|
||||
const cfg = periodConfig[periodKey];
|
||||
|
||||
// group & stats
|
||||
const groups = {};
|
||||
all.forEach(r => {
|
||||
const key = cfg.keyFn(r);
|
||||
groups[key] = groups[key] || { temps:[], hums:[], his:[] };
|
||||
groups[key].temps.push(r.temperature);
|
||||
groups[key].hums.push(r.humidity);
|
||||
groups[key].his.push(r.heatIndex);
|
||||
});
|
||||
|
||||
const labels = Object.keys(groups).sort();
|
||||
const stats = labels.map(k => {
|
||||
const g = groups[k];
|
||||
return {
|
||||
temp: { avg: average(g.temps).toFixed(2), min: Math.min(...g.temps), max: Math.max(...g.temps) },
|
||||
hum: { avg: average(g.hums).toFixed(2), min: Math.min(...g.hums), max: Math.max(...g.hums) },
|
||||
hi: { avg: average(g.his).toFixed(2), min: Math.min(...g.his), max: Math.max(...g.his) }
|
||||
};
|
||||
});
|
||||
|
||||
// selected toggles
|
||||
const checks = Array.from(document.querySelectorAll('#metricToggles input:checked'));
|
||||
const datasets = checks.map(chk => {
|
||||
const m = chk.dataset.metric, s = chk.dataset.stat;
|
||||
return {
|
||||
label: `${m.toUpperCase()} ${s.toUpperCase()}`,
|
||||
data: stats.map(x => x[m][s]),
|
||||
fill: false,
|
||||
tension: 0.1
|
||||
};
|
||||
});
|
||||
|
||||
const ctx = document.getElementById('trendChart').getContext('2d');
|
||||
if (trendChart) {
|
||||
trendChart.data.labels = labels.map(cfg.labelFn);
|
||||
trendChart.data.datasets = datasets;
|
||||
trendChart.update();
|
||||
} else {
|
||||
trendChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels.map(cfg.labelFn),
|
||||
datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
tooltip: { mode:'index', intersect:false },
|
||||
zoom: {
|
||||
pan: { enabled:true, mode:'x', modifierKey:'ctrl' },
|
||||
zoom: { wheel:{enabled:true}, pinch:{enabled:true}, mode:'x' }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { display:true },
|
||||
y: { display:true }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// always update table below
|
||||
renderTable(all);
|
||||
}
|
||||
|
||||
// wire up controls
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// initial draw
|
||||
drawTrend();
|
||||
|
||||
// slider
|
||||
document.getElementById('periodSlider')
|
||||
.addEventListener('input', e => {
|
||||
document.getElementById('periodLabel').textContent = periodLabels[e.target.value];
|
||||
drawTrend();
|
||||
});
|
||||
|
||||
// toggles
|
||||
document.querySelectorAll('#metricToggles input')
|
||||
.forEach(chk => chk.addEventListener('change', drawTrend));
|
||||
});
|
||||
|
@ -1,25 +1,45 @@
|
||||
/* Favicon support (no CSS needed) */
|
||||
/* ================ CSS VARIABLES ================ */
|
||||
:root {
|
||||
--square-size: 60px;
|
||||
--gap: 8px;
|
||||
--v-gap: 24px;
|
||||
--warehouse-height: 200px;
|
||||
}
|
||||
|
||||
/* BINS Project Header & Buttons */
|
||||
/* ================ GLOBAL & LAYOUT ================ */
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f4f4f4;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ================ BINS HEADER & NAV BUTTONS ================ */
|
||||
.main-header {
|
||||
background-color: #232F3E;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
position: fixed; top: 0; left: 0; right: 0;
|
||||
width: 100%; z-index: 1000;
|
||||
}
|
||||
.main-header .logo {
|
||||
padding: 0.5rem 2rem;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
.logo {
|
||||
height: 40px;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.main-header .header-title {
|
||||
}
|
||||
.header-title {
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
margin-right: auto;
|
||||
}
|
||||
.main-header .nav-btn {
|
||||
}
|
||||
.nav-btn {
|
||||
background-color: #FF9900;
|
||||
color: #111;
|
||||
border: none;
|
||||
@ -28,36 +48,129 @@
|
||||
margin-left: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
.main-header .nav-btn:hover {
|
||||
}
|
||||
.nav-btn:hover {
|
||||
background-color: #e48f00;
|
||||
}
|
||||
.page-container {
|
||||
margin-top: 70px;
|
||||
width: 95%; margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ================ PAGE CONTAINER ================ */
|
||||
/* full-height container under header, using flex for trends page */
|
||||
.page-container {
|
||||
margin-top: 70px; /* offset header */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: calc(100vh - 70px);
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* ================ FORM STYLES ================ */
|
||||
.form-input {
|
||||
width: 80%;
|
||||
max-width: 400px;
|
||||
margin: 1rem auto;
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.form-input, .form-textarea {
|
||||
width: 90%; margin: 0.5rem auto; display: block;
|
||||
padding: 0.5rem; border: 1px solid #ccc; border-radius: 6px;
|
||||
}
|
||||
.big-button {
|
||||
background-color: #28a745; color: white;
|
||||
padding: 0.75rem 1.5rem; border: none; border-radius: 6px;
|
||||
cursor: pointer; font-size: 1rem;
|
||||
margin: 1rem auto; display: block;
|
||||
}
|
||||
.big-button:hover { background-color: #218838; }
|
||||
.hidden { display: none; }
|
||||
}
|
||||
.big-button {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin: 1rem auto;
|
||||
display: block;
|
||||
}
|
||||
.big-button:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
fieldset {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
legend {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Diagram styling */
|
||||
.diagram-container { width: 95%; margin: 0 auto; text-align: center; }
|
||||
.dock-row { display: grid; grid-template-columns: repeat(83, 1fr); gap: 4px; margin-bottom: 8px; }
|
||||
.dock-square { width: 100%; padding-top: 100%; position: relative; background: #ddd; border-radius: 2px; }
|
||||
.dock-square::after { content: attr(data-door); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.6); color: #333; }
|
||||
.warehouse { width: 95%; height: 200px; margin: 0 auto; background: #ccc; border-radius: 6px; }
|
||||
/* ================ TRENDS PAGE SPECIFIC ================ */
|
||||
/* Controls area stays at top */
|
||||
#trend-controls {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* Chart container fills all remaining height */
|
||||
.chart-container {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.chart-container canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Existing element styles */
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
/* ================ HEATMAP STYLES ================= */
|
||||
/* (unchanged from before) */
|
||||
.heatmap-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
.diagram-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--v-gap);
|
||||
}
|
||||
.dock-row {
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
}
|
||||
.dock-square {
|
||||
width: var(--square-size);
|
||||
height: var(--square-size);
|
||||
background: #ddd;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.warehouse {
|
||||
width: calc((var(--square-size) * 83) + (var(--gap) * 82));
|
||||
height: var(--warehouse-height);
|
||||
background: #ccc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #fff;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
display: none;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
/* ================ UTILITY ================= */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
30
s3.js
Normal file
30
s3.js
Normal file
@ -0,0 +1,30 @@
|
||||
// s3.js
|
||||
require('dotenv').config();
|
||||
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { stringify } = require('csv-stringify/sync');
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: process.env.AWS_REGION
|
||||
});
|
||||
|
||||
/**
|
||||
* Uploads a CSV to s3://<bucket>/trends/<key>.csv with public-read ACL.
|
||||
* @param {string} key Date key like '20250423'
|
||||
* @param {Array<Object>} rows Array of DB rows to stringify into CSV
|
||||
* @returns {string} Public URL of the uploaded CSV
|
||||
*/
|
||||
async function uploadTrendsCsv(key, rows) {
|
||||
// Convert rows to CSV string (includes header)
|
||||
const csv = stringify(rows, { header: true });
|
||||
const cmd = new PutObjectCommand({
|
||||
Bucket: process.env.S3_BUCKET_NAME,
|
||||
Key: `trends/${key}.csv`,
|
||||
Body: csv,
|
||||
ContentType: 'text/csv',
|
||||
ACL: 'public-read'
|
||||
});
|
||||
await s3.send(cmd);
|
||||
return `${process.env.S3_BASE_URL}/${key}.csv`;
|
||||
}
|
||||
|
||||
module.exports = { uploadTrendsCsv };
|
375
server.js
375
server.js
@ -10,295 +10,197 @@ const { uploadTrendsCsv } = require('./s3');
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// In-memory shift counters
|
||||
// In‑memory shift counters
|
||||
const shiftCounters = {};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
// pad to two digits
|
||||
function pad2(n) {
|
||||
return n.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
// Format epoch_ms → "M/D/YY @HH:mm" (24-hour) 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(',', ' @');
|
||||
}
|
||||
|
||||
// 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 & period key from epoch_ms
|
||||
function getShiftInfo(epoch) {
|
||||
// Convert to EST by string-round-trip
|
||||
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 Baltimore weather
|
||||
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 ───────────────────────────────────────────────
|
||||
// Create MariaDB connection pool
|
||||
// server.js (pool section)
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT,10) || 3306,
|
||||
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
|
||||
connectTimeout: 10000 // give it up to 10s before timing out
|
||||
});
|
||||
|
||||
|
||||
// Ensure readings table exists
|
||||
(async () => {
|
||||
// Create table with epoch_ms only
|
||||
await pool.execute(`
|
||||
const createSQL = `
|
||||
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,
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
dockDoor INT NOT NULL,
|
||||
timestamp DATETIME 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;
|
||||
`);
|
||||
heatIndex DOUBLE
|
||||
);
|
||||
`;
|
||||
await pool.execute(createSQL);
|
||||
})();
|
||||
|
||||
// ─── Middleware & Static ─────────────────────────────────────────────────────
|
||||
app.use(bodyParser.json());
|
||||
app.use(express.static(path.join(__dirname,'public'), { index:'heatmap.html' }));
|
||||
|
||||
// ─── SSE Setup ────────────────────────────────────────────────────────────────
|
||||
// Simple SSE setup
|
||||
let clients = [];
|
||||
app.get('/api/stream',(req,res)=>{
|
||||
app.get('/api/stream', (req, res) => {
|
||||
res.set({
|
||||
'Content-Type':'text/event-stream',
|
||||
'Cache-Control':'no-cache',
|
||||
Connection:'keep-alive'
|
||||
'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); });
|
||||
req.on('close', () => {
|
||||
clients = clients.filter(c => c !== res);
|
||||
});
|
||||
});
|
||||
function broadcast(event,data){
|
||||
function broadcast(event, data) {
|
||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
clients.forEach(c=>c.write(msg));
|
||||
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 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 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 key = `${shift}-${shiftStart.toISOString().slice(0,10)}-${shiftStart.getHours()}${shiftStart.getMinutes()}`;
|
||||
return { shift, shiftStart, key, estNow };
|
||||
}
|
||||
|
||||
// Middleware & static files
|
||||
app.use(bodyParser.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Dual‐reading POST endpoint
|
||||
app.post('/api/readings', async (req, res) => {
|
||||
try {
|
||||
const { inbound, outbound } = req.body;
|
||||
const hiIn = computeHeatIndex(inbound_temperature, inbound_humidity);
|
||||
const hiOut = computeHeatIndex(outbound_temperature, outbound_humidity);
|
||||
|
||||
// Shift & period logic
|
||||
const { shift, shiftStart, key, estNow } = getShiftInfo(new Date());
|
||||
shiftCounters[key] = (shiftCounters[key] || 0) + 1;
|
||||
const period = shiftCounters[key];
|
||||
const slackTs = formatForSlack(epoch);
|
||||
|
||||
// Insert inbound + outbound
|
||||
const sql = `
|
||||
INSERT INTO readings(location,stationDockDoor,epoch_ms,temperature,humidity,heatIndex)
|
||||
VALUES(?,?,?,?,?,?)
|
||||
// Insert both readings with server‐side NOW()
|
||||
const insertSQL = `
|
||||
INSERT INTO readings
|
||||
(dockDoor, timestamp, temperature, humidity, heatIndex)
|
||||
VALUES (?, NOW(), ?, ?, ?)
|
||||
`;
|
||||
await pool.execute(sql, ['Inbound', String(inD), epoch, inT, inH, hiIn]);
|
||||
await pool.execute(sql, ['Outbound', String(outD), epoch, outT, outH, hiOut]);
|
||||
await pool.execute(insertSQL, [inbound_dockDoor, inbound_temperature, inbound_humidity, hiIn]);
|
||||
await pool.execute(insertSQL, [outbound_dockDoor, outbound_temperature, outbound_humidity, 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
|
||||
// Broadcast to SSE clients (use UTC ISO for front‐end)
|
||||
const isoNow = new Date().toISOString();
|
||||
broadcast('new-reading', { dockDoor: inbound_dockDoor, timestamp: isoNow, ...inbound, heatIndex: hiIn });
|
||||
broadcast('new-reading', { dockDoor: outbound_dockDoor, timestamp: isoNow, ...outbound, heatIndex: hiOut });
|
||||
|
||||
// Format EST timestamp for Slack
|
||||
const estString = estNow.toLocaleString('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
hour12: false,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
|
||||
// CSV upload
|
||||
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); }
|
||||
// Base Slack text
|
||||
let slackText =
|
||||
`${shift} Shift ${period} temperature checks for ${estString} EST
|
||||
|
||||
// Slack message
|
||||
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 🥵`;
|
||||
Inbound Dock Door: ${inbound_dockDoor}
|
||||
Temp: ${inbound_temperature}°F
|
||||
Humidity: ${inbound_humidity}%
|
||||
Heat Index: ${hiIn}
|
||||
|
||||
await axios.post(process.env.SLACK_WEBHOOK_URL, { text }, {
|
||||
headers:{ 'Content-Type':'application/json' }
|
||||
});
|
||||
Outbound Dock Door: ${outbound_dockDoor}
|
||||
Temp: ${outbound_temperature}°F
|
||||
Humidity: ${outbound_humidity}%
|
||||
Heat Index: ${hiOut}`;
|
||||
|
||||
res.json({ success:true, shift, period, csvUrl });
|
||||
// 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(
|
||||
`SELECT * FROM readings
|
||||
WHERE DATE(timestamp) = CURDATE()
|
||||
ORDER BY timestamp ASC`
|
||||
);
|
||||
try {
|
||||
const publicUrl = await uploadTrendsCsv(dateKey, rows);
|
||||
slackText += `\n\nDownload daily trends: ${publicUrl}`;
|
||||
} catch(uploadErr) {
|
||||
console.error('Failed to upload CSV to S3:', uploadErr);
|
||||
}
|
||||
|
||||
// Send Slack notification
|
||||
await axios.post(process.env.SLACK_WEBHOOK_URL, { text: slackText });
|
||||
|
||||
res.json({ success: true, shift, period });
|
||||
} catch (err) {
|
||||
console.error('POST /api/readings error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Area/Mod Endpoint ───────────────────────────────────────────────────────
|
||||
app.post('/api/area-readings', async (req,res) => {
|
||||
// Return all readings as JSON
|
||||
app.get('/api/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 ─────────────────────────────────────────────────────────
|
||||
app.get('/api/readings', async (req,res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY epoch_ms`);
|
||||
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
|
||||
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) => {
|
||||
|
||||
// Export CSV of all readings
|
||||
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');
|
||||
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
|
||||
res.setHeader('Content-disposition', 'attachment; filename=readings.csv');
|
||||
res.set('Content-Type', 'text/csv');
|
||||
res.write('id,dockDoor,timestamp,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`
|
||||
);
|
||||
// ensure ISO timestamp
|
||||
const ts = (r.timestamp instanceof Date)
|
||||
? r.timestamp.toISOString()
|
||||
: r.timestamp;
|
||||
res.write(`${r.id},${r.dockDoor},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`);
|
||||
});
|
||||
res.end();
|
||||
} catch (err) {
|
||||
@ -307,7 +209,6 @@ app.get('/api/export', async (req,res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Start Server ────────────────────────────────────────────────────────────
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user