Compare commits
5 Commits
main
...
feature/do
Author | SHA1 | Date | |
---|---|---|---|
b2b8b8bde4 | |||
4810b4e54f | |||
![]() |
697eb80d9c | ||
![]() |
3c26875e5e | ||
![]() |
8738371bbb |
21
.env
21
.env
@ -3,8 +3,19 @@ PORT=3000
|
|||||||
|
|
||||||
# MariaDB connection
|
# MariaDB connection
|
||||||
DB_CLIENT=mysql
|
DB_CLIENT=mysql
|
||||||
DB_HOST=localhost
|
DB_HOST=0.0.0.0
|
||||||
DB_PORT=3306
|
DB_PORT=3307
|
||||||
DB_USER=your_db_user
|
DB_USER=joshbaney
|
||||||
DB_PASSWORD=your_db_password
|
DB_PASSWORD=Ran0dal5!
|
||||||
DB_NAME=warehouse_heatmap
|
DB_NAME=heatmap
|
||||||
|
|
||||||
|
#AWS s3
|
||||||
|
S3_BUCKET_URL=https://s3.amazonaws.com/bwi2temps/trends
|
||||||
|
|
||||||
|
# Slack & AWS creds
|
||||||
|
SLACK_WEBHOOK_URL=https://hooks.slack.com/triggers/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
|
||||||
|
AWS_ACCESS_KEY_ID=ihya/m4CONlywOPCNER22oZrbOeCdJLxp3R4H3oF
|
||||||
|
AWS_SECRET_ACCESS_KEY=AKIAQ3EGSIYOYP4L37HH
|
||||||
|
AWS_REGION=us-east-2
|
||||||
|
S3_BUCKET_NAME=bwi2temps
|
||||||
|
S3_BASE_URL=https://s3.amazonaws.com/bwi2temps/
|
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Dockerfile
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# Bundle app source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port and run
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
14
docker-compose.yaml
Normal file
14
docker-compose.yaml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
fuego-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
restart: unless-stopped
|
12
package.json
12
package.json
@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "warehouse-heatmap-app",
|
"name": "Amazon-Fuego",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"express": "^4.18.2",
|
||||||
"mysql2": "^3.2.0",
|
"mysql2": "^3.3.3",
|
||||||
"knex": "^2.4.2",
|
"sqlite3": "^5.1.6"
|
||||||
"body-parser": "^1.20.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -5,11 +5,13 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<title>Warehouse Heat Map</title>
|
<title>Heat Map</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- BINS‑style fixed header -->
|
||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
<img src="/image/logo.png" alt="Amazon Logo" class="logo">
|
<img src="/image/logo.png" class="logo" alt="Amazon Logo">
|
||||||
<span class="header-title"> | Fuego - Heat Tracker</span>
|
<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='/input.html'">Log Reading</button>
|
||||||
<button class="nav-btn" onclick="location.href='/heatmap.html'">Heat Map</button>
|
<button class="nav-btn" onclick="location.href='/heatmap.html'">Heat Map</button>
|
||||||
@ -17,12 +19,21 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<div class="diagram-container">
|
<div class="heatmap-wrapper">
|
||||||
|
<div id="diagram" class="diagram-container">
|
||||||
<div id="dock-row" class="dock-row"></div>
|
<div id="dock-row" class="dock-row"></div>
|
||||||
<div class="warehouse"></div>
|
<div class="warehouse"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tooltip" class="tooltip"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="scripts/heatmap.js"></script>
|
<script src="scripts/heatmap.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="scripts/heatmap.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
71
public/index.html
Normal file
71
public/index.html
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Fuego – Heat Tracker</title>
|
||||||
|
<!-- your other CSS/links here -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 1) Scrollable container taking full viewport height */
|
||||||
|
#scroll-container {
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2) WebKit browsers */
|
||||||
|
#scroll-container::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
#scroll-container::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
#scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #888;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 3px solid #f1f1f1;
|
||||||
|
}
|
||||||
|
#scroll-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3) Firefox */
|
||||||
|
#scroll-container {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #888 #f1f1f1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="scroll-container">
|
||||||
|
<!-- Your graph/chart -->
|
||||||
|
<div id="graph" style="height: 60vh; padding: 1rem;">
|
||||||
|
<!-- e.g. <canvas id="myChart"></canvas> or your chart library mount point -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Your spreadsheet/table -->
|
||||||
|
<div id="spreadsheet" style="height: 60vh; padding: 1rem;">
|
||||||
|
<!-- e.g. a table or your react/vanilla table component -->
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date/Time</th>
|
||||||
|
<th>Temperature</th>
|
||||||
|
<th>Humidity</th>
|
||||||
|
<th>Heat Index</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Direction</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- rows go here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- your scripts here -->
|
||||||
|
<script src="/socket.io.js"></script>
|
||||||
|
<script src="main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -37,3 +37,41 @@
|
|||||||
<script src="scripts/input.js"></script>
|
<script src="scripts/input.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
<script>
|
||||||
|
// make sure this runs *after* your form is in the DOM
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const form = document.getElementById('readingForm');
|
||||||
|
form.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
// grab your values
|
||||||
|
const inbound_dockDoor = +document.querySelector('input[name="inbound_dockDoor"]').value;
|
||||||
|
const inbound_temperature = +document.querySelector('input[name="inbound_temperature"]').value;
|
||||||
|
const inbound_humidity = +document.querySelector('input[name="inbound_humidity"]').value;
|
||||||
|
const outbound_dockDoor = +document.querySelector('input[name="outbound_dockDoor"]').value;
|
||||||
|
const outbound_temperature = +document.querySelector('input[name="outbound_temperature"]').value;
|
||||||
|
const outbound_humidity = +document.querySelector('input[name="outbound_humidity"]').value;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
inbound_dockDoor,
|
||||||
|
inbound_temperature,
|
||||||
|
inbound_humidity,
|
||||||
|
outbound_dockDoor,
|
||||||
|
outbound_temperature,
|
||||||
|
outbound_humidity
|
||||||
|
};
|
||||||
|
console.log('👉 Sending payload:', payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/readings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
console.log('✅ Server response:', data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Fetch error:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@ -1,51 +1,129 @@
|
|||||||
// door ranges
|
// dock‑door ranges
|
||||||
const doors = [
|
const doors = [
|
||||||
...Array.from({length: 138 - 124 + 1}, (_, i) => 124 + i),
|
...Array.from({length: 138 - 124 + 1}, (_, i) => 124 + i),
|
||||||
...Array.from({length: 201 - 142 + 1}, (_, i) => 142 + i),
|
...Array.from({length: 201 - 142 + 1}, (_, i) => 142 + i),
|
||||||
...Array.from({length: 209 - 202 + 1}, (_, i) => 202 + i),
|
...Array.from({length: 209 - 202 + 1}, (_, i) => 202 + i),
|
||||||
];
|
];
|
||||||
|
|
||||||
// NOAA heat‐index formula
|
// store readings per door
|
||||||
|
const doorData = {};
|
||||||
|
|
||||||
|
// color by heat index
|
||||||
function getColorFromHI(H) {
|
function getColorFromHI(H) {
|
||||||
const pct = Math.min(Math.max((H - 70) / 30, 0), 1);
|
const pct = Math.min(Math.max((H - 70) / 30, 0), 1);
|
||||||
const r = 255;
|
const r = 255, g = Math.round(255 * (1 - pct));
|
||||||
const g = Math.round(255 * (1 - pct));
|
|
||||||
return `rgba(${r},${g},0,0.8)`;
|
return `rgba(${r},${g},0,0.8)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// build the row of squares
|
||||||
function createGrid() {
|
function createGrid() {
|
||||||
const row = document.getElementById('dock-row');
|
const row = document.getElementById('dock-row');
|
||||||
doors.forEach(d => {
|
doors.forEach(d => {
|
||||||
|
doorData[d] = [];
|
||||||
const sq = document.createElement('div');
|
const sq = document.createElement('div');
|
||||||
sq.className = 'dock-square';
|
sq.className = 'dock-square';
|
||||||
sq.dataset.door = d;
|
sq.dataset.door = d;
|
||||||
|
sq.textContent = d;
|
||||||
row.appendChild(sq);
|
row.appendChild(sq);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply a new reading to its square
|
// compute stats
|
||||||
function colorize(door, hi) {
|
function computeStats(arr) {
|
||||||
const sq = document.querySelector(`.dock-square[data-door="${door}"]`);
|
if (!arr.length) return null;
|
||||||
if (!sq) return;
|
let sum = 0, max = arr[0], min = arr[0];
|
||||||
sq.style.background = getColorFromHI(hi);
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
// color a square
|
||||||
createGrid();
|
function fillSquare(door, hi) {
|
||||||
// initial fill
|
const sq = document.querySelector(`.dock-square[data-door="${door}"]`);
|
||||||
const all = await fetch('/api/readings').then(r=>r.json());
|
if (sq) sq.style.background = getColorFromHI(hi);
|
||||||
// pick latest per door
|
}
|
||||||
const latest = {};
|
|
||||||
all.forEach(r => { latest[r.dockDoor] = r.heatIndex; });
|
|
||||||
Object.entries(latest).forEach(([door, hi]) => colorize(door, hi));
|
|
||||||
|
|
||||||
// subscribe SSE
|
// load initial readings
|
||||||
const es = new EventSource('/api/stream');
|
async function loadInitial() {
|
||||||
es.addEventListener('new-reading', e => {
|
const all = await fetch('/api/readings').then(r => r.json());
|
||||||
const { dockDoor, heatIndex } = JSON.parse(e.data);
|
all.forEach(r => {
|
||||||
colorize(dockDoor, heatIndex);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
// real‑time updates
|
||||||
|
function subscribeRealtime() {
|
||||||
|
const es = new EventSource('/api/stream');
|
||||||
|
es.addEventListener('new-reading', e => {
|
||||||
|
const r = JSON.parse(e.data);
|
||||||
|
if (doorData[r.dockDoor]) {
|
||||||
|
doorData[r.dockDoor].push(r);
|
||||||
|
fillSquare(r.dockDoor, r.heatIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,40 +1,161 @@
|
|||||||
const ctxD = document.getElementById('dailyChart').getContext('2d');
|
// average helper
|
||||||
const ctxW = document.getElementById('weeklyChart').getContext('2d');
|
function average(arr) {
|
||||||
const ctxM = document.getElementById('monthlyChart').getContext('2d');
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||||
|
}
|
||||||
|
|
||||||
async function drawTrend(ctx, period) {
|
// interval mappings
|
||||||
const all = await fetch('/api/readings').then(r=>r.json());
|
const periodKeys = ['hourly','daily','weekly','monthly','yearly'];
|
||||||
// group by day/week/month
|
const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly'];
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
};
|
||||||
|
|
||||||
|
// global Chart.js instance
|
||||||
|
let trendChart, dataTable;
|
||||||
|
|
||||||
|
// fetch readings from server
|
||||||
|
async function fetchReadings() {
|
||||||
|
const res = await fetch('/api/readings');
|
||||||
|
return res.ok ? res.json() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine direction based on dock door #
|
||||||
|
function getDirection(dock) {
|
||||||
|
return ( (dock>=124 && dock<=138) || (dock>=202 && dock<=209) )
|
||||||
|
? 'Inbound'
|
||||||
|
: 'Outbound';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(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 = {};
|
const groups = {};
|
||||||
all.forEach(r => {
|
all.forEach(r => {
|
||||||
const d = new Date(r.timestamp);
|
const key = cfg.keyFn(r);
|
||||||
let key;
|
groups[key] = groups[key] || { temps:[], hums:[], his:[] };
|
||||||
if (period==='daily') key = d.toISOString().slice(0,10);
|
groups[key].temps.push(r.temperature);
|
||||||
if (period==='weekly') key = `${d.getFullYear()}-W${Math.ceil(d.getDate()/7)}`;
|
groups[key].hums.push(r.humidity);
|
||||||
if (period==='monthly') key = d.toISOString().slice(0,7);
|
groups[key].his.push(r.heatIndex);
|
||||||
groups[key] = groups[key]||[];
|
|
||||||
groups[key].push(r.heatIndex);
|
|
||||||
});
|
});
|
||||||
const labels = Object.keys(groups);
|
|
||||||
const data = labels.map(k => {
|
const labels = Object.keys(groups).sort();
|
||||||
const arr = groups[k];
|
const stats = labels.map(k => {
|
||||||
|
const g = groups[k];
|
||||||
return {
|
return {
|
||||||
max: Math.max(...arr),
|
temp: { avg: average(g.temps).toFixed(2), min: Math.min(...g.temps), max: Math.max(...g.temps) },
|
||||||
min: Math.min(...arr),
|
hum: { avg: average(g.hums).toFixed(2), min: Math.min(...g.hums), max: Math.max(...g.hums) },
|
||||||
avg: arr.reduce((a,b)=>a+b,0)/arr.length
|
hi: { avg: average(g.his).toFixed(2), min: Math.min(...g.his), max: Math.max(...g.his) }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
new Chart(ctx, {
|
|
||||||
|
// 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',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels,
|
labels: labels.map(cfg.labelFn),
|
||||||
datasets: [
|
datasets
|
||||||
{ label: 'Max', data: data.map(x=>x.max) },
|
},
|
||||||
{ label: 'Avg', data: data.map(x=>x.avg) },
|
options: {
|
||||||
{ label: 'Min', data: data.map(x=>x.min) }
|
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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
['daily','weekly','monthly'].forEach((p,i)=>drawTrend([ctxD,ctxW,ctxM][i], p));
|
// 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 {
|
.main-header {
|
||||||
background-color: #232F3E;
|
background-color: #232F3E;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 2rem;
|
||||||
position: fixed; top: 0; left: 0; right: 0;
|
position: fixed;
|
||||||
width: 100%; z-index: 1000;
|
top: 0; left: 0; right: 0;
|
||||||
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
.main-header .logo {
|
.logo {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
}
|
}
|
||||||
.main-header .header-title {
|
.header-title {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
.main-header .nav-btn {
|
.nav-btn {
|
||||||
background-color: #FF9900;
|
background-color: #FF9900;
|
||||||
color: #111;
|
color: #111;
|
||||||
border: none;
|
border: none;
|
||||||
@ -29,35 +49,128 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.main-header .nav-btn:hover {
|
.nav-btn:hover {
|
||||||
background-color: #e48f00;
|
background-color: #e48f00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================ PAGE CONTAINER ================ */
|
||||||
|
/* full-height container under header, using flex for trends page */
|
||||||
.page-container {
|
.page-container {
|
||||||
margin-top: 70px;
|
margin-top: 70px; /* offset header */
|
||||||
width: 95%; margin: 0 auto;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 70px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #fff;
|
||||||
padding: 1rem;
|
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;
|
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 {
|
.big-button {
|
||||||
background-color: #28a745; color: white;
|
background-color: #28a745;
|
||||||
padding: 0.75rem 1.5rem; border: none; border-radius: 6px;
|
color: white;
|
||||||
cursor: pointer; font-size: 1rem;
|
padding: 0.75rem 1.5rem;
|
||||||
margin: 1rem auto; display: block;
|
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;
|
||||||
}
|
}
|
||||||
.big-button:hover { background-color: #218838; }
|
|
||||||
.hidden { display: none; }
|
|
||||||
|
|
||||||
/* Diagram styling */
|
/* ================ TRENDS PAGE SPECIFIC ================ */
|
||||||
.diagram-container { width: 95%; margin: 0 auto; text-align: center; }
|
/* Controls area stays at top */
|
||||||
.dock-row { display: grid; grid-template-columns: repeat(83, 1fr); gap: 4px; margin-bottom: 8px; }
|
#trend-controls {
|
||||||
.dock-square { width: 100%; padding-top: 100%; position: relative; background: #ddd; border-radius: 2px; }
|
flex: 0 0 auto;
|
||||||
.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; }
|
/* 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 */
|
/* ================ HEATMAP STYLES ================= */
|
||||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
/* (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;
|
||||||
|
}
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<link rel="stylesheet" href="styles.css" />
|
||||||
<link rel="stylesheet" href="styles.css">
|
<!-- DataTables CSS for table styling, sorting arrows, etc. -->
|
||||||
<title>| Fuego - Heat Tracker</title>
|
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css" />
|
||||||
|
<title>Trend Graph & Table</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
<img src="/image/logo.png" alt="Amazon Logo" class="logo">
|
<img src="/image/logo.png" class="logo" alt="Amazon Logo" />
|
||||||
<span class="header-title"> | Fuego - Heat Tracker</span>
|
<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='/input.html'">Log Reading</button>
|
||||||
<button class="nav-btn" onclick="location.href='/heatmap.html'">Heat Map</button>
|
<button class="nav-btn" onclick="location.href='/heatmap.html'">Heat Map</button>
|
||||||
@ -18,10 +19,60 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<canvas id="dailyChart"></canvas>
|
<h1>Trend Analysis</h1>
|
||||||
<canvas id="weeklyChart"></canvas>
|
|
||||||
<canvas id="monthlyChart"></canvas>
|
<div id="trend-controls" style="text-align:center; margin-bottom:1rem;">
|
||||||
|
<!-- Interval slider -->
|
||||||
|
<label for="periodSlider">
|
||||||
|
Interval: <strong><span id="periodLabel">Daily</span></strong>
|
||||||
|
</label><br/>
|
||||||
|
<input type="range" id="periodSlider" min="0" max="4" step="1" value="1" />
|
||||||
|
|
||||||
|
<!-- Metric/stat toggles -->
|
||||||
|
<div id="metricToggles" style="margin-top:1rem;">
|
||||||
|
<label><input type="checkbox" data-metric="temp" data-stat="avg" checked /> Temp Avg</label>
|
||||||
|
<label><input type="checkbox" data-metric="temp" data-stat="min" checked /> Temp Min</label>
|
||||||
|
<label><input type="checkbox" data-metric="temp" data-stat="max" checked /> Temp Max</label>
|
||||||
|
<label><input type="checkbox" data-metric="hum" data-stat="avg" checked /> Hum Avg</label>
|
||||||
|
<label><input type="checkbox" data-metric="hum" data-stat="min" checked /> Hum Min</label>
|
||||||
|
<label><input type="checkbox" data-metric="hum" data-stat="max" checked /> Hum Max</label>
|
||||||
|
<label><input type="checkbox" data-metric="hi" data-stat="avg" checked /> HI Avg</label>
|
||||||
|
<label><input type="checkbox" data-metric="hi" data-stat="min" checked /> HI Min</label>
|
||||||
|
<label><input type="checkbox" data-metric="hi" data-stat="max" checked /> HI Max</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="trendChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table container -->
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="trendTable" class="display" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date/Time</th>
|
||||||
|
<th>Temperature (°F)</th>
|
||||||
|
<th>Humidity (%)</th>
|
||||||
|
<th>Heat Index</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Direction</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- populated dynamically -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart.js and zoom plugin -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||||||
|
<!-- jQuery + DataTables -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
|
||||||
|
<!-- Your trends logic -->
|
||||||
<script src="scripts/trends.js"></script>
|
<script src="scripts/trends.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
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 };
|
257
server.js
257
server.js
@ -1,126 +1,243 @@
|
|||||||
|
// server.js
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const knex = require('knex');
|
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
const { uploadTrendsCsv } = require('./s3');
|
||||||
// Initialize MariaDB connection via Knex
|
|
||||||
const db = knex({
|
|
||||||
client: process.env.DB_CLIENT,
|
|
||||||
connection: {
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
port: process.env.DB_PORT,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const slackWebhook = process.env.SLACK_WEBHOOK_URL;
|
|
||||||
|
|
||||||
// Create table if not exists, now with direction
|
// In-memory shift counters
|
||||||
(async () => {
|
const shiftCounters = {};
|
||||||
if (!await db.schema.hasTable('readings')) {
|
|
||||||
await db.schema.createTable('readings', table => {
|
// Helper: format a Date in EST as SQL DATETIME string
|
||||||
table.increments('id').primary();
|
function formatDateEST(date) {
|
||||||
table.integer('dockDoor');
|
const pad = n => n.toString().padStart(2, '0');
|
||||||
table.string('direction');
|
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ` +
|
||||||
table.timestamp('timestamp');
|
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
table.float('temperature');
|
}
|
||||||
table.float('humidity');
|
|
||||||
table.float('heatIndex');
|
// Helper: format a Date in EST as ISO-like string (no “Z”)
|
||||||
});
|
function isoStringEST(date) {
|
||||||
|
const pad = n => n.toString().padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}` +
|
||||||
|
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
// Compute heat index (NOAA formula)
|
// Compute heat index (NOAA formula)
|
||||||
function computeHeatIndex(T, R) {
|
function computeHeatIndex(T, R) {
|
||||||
const c1 = -42.379, c2 = 2.04901523, c3 = 10.14333127;
|
const [c1,c2,c3,c4,c5,c6,c7,c8,c9] =
|
||||||
const c4 = -0.22475541, c5 = -6.83783e-3, c6 = -5.481717e-2;
|
[-42.379,2.04901523,10.14333127,-0.22475541,-0.00683783,
|
||||||
const c7 = 1.22874e-3, c8 = 8.5282e-4, c9 = -1.99e-6;
|
-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;
|
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;
|
return Math.round(HI * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine direction based on door number
|
// Determine shift info in EST
|
||||||
function getDirection(door) {
|
function getShiftInfo(now) {
|
||||||
door = Number(door);
|
const estNow = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
||||||
if (door >= 124 && door <= 138) return 'Inbound';
|
const [h,m] = [estNow.getHours(), estNow.getMinutes()];
|
||||||
if (door >= 142 && door <= 201) return 'Outbound';
|
let shift, shiftStart = new Date(estNow);
|
||||||
if (door >= 202 && door <= 209) return 'Inbound';
|
|
||||||
return 'Unknown';
|
if (h > 7 || (h === 7 && m >= 0)) {
|
||||||
|
if (h < 17 || (h === 17 && m < 30)) {
|
||||||
|
shift = 'Day';
|
||||||
|
shiftStart.setHours(7,0,0,0);
|
||||||
|
} else {
|
||||||
|
shift = 'Night';
|
||||||
|
shiftStart.setHours(17,30,0,0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shift = 'Night';
|
||||||
|
shiftStart.setDate(shiftStart.getDate() - 1);
|
||||||
|
shiftStart.setHours(17,30,0,0);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(bodyParser.json());
|
const key = `${shift}-${shiftStart.toISOString().slice(0,10)}-` +
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
`${shiftStart.getHours()}${shiftStart.getMinutes()}`;
|
||||||
|
return { shift, shiftStart, key, estNow };
|
||||||
|
}
|
||||||
|
|
||||||
|
// MariaDB connection 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,
|
||||||
|
enableKeepAlive: true,
|
||||||
|
keepAliveInitialDelay: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure readings table exists
|
||||||
|
(async () => {
|
||||||
|
const createSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS readings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
dockDoor INT NOT NULL,
|
||||||
|
direction VARCHAR(10) NOT NULL,
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
temperature DOUBLE,
|
||||||
|
humidity DOUBLE,
|
||||||
|
heatIndex DOUBLE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
await pool.execute(createSQL);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// SSE setup
|
||||||
let clients = [];
|
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' });
|
res.set({
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive'
|
||||||
|
});
|
||||||
res.flushHeaders();
|
res.flushHeaders();
|
||||||
clients.push(res);
|
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 payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
clients.forEach(res => res.write(payload));
|
clients.forEach(c => c.write(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
app.post('/api/readings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { inbound, outbound } = req.body; // each: {dockDoor,temperature,humidity}
|
console.log('🔔 POST /api/readings body:', req.body);
|
||||||
const timestamp = new Date();
|
const { inbound = {}, outbound = {} } = req.body;
|
||||||
const entries = [inbound, outbound].map(r => {
|
const { dockDoor: inDoor, temperature: inTemp, humidity: inHum } = inbound;
|
||||||
const direction = getDirection(r.dockDoor);
|
const { dockDoor: outDoor, temperature: outTemp, humidity: outHum } = outbound;
|
||||||
const heatIndex = computeHeatIndex(r.temperature, r.humidity);
|
|
||||||
return { ...r, direction, timestamp, heatIndex };
|
|
||||||
});
|
|
||||||
// Insert both
|
|
||||||
const ids = await db('readings').insert(entries);
|
|
||||||
const saved = entries.map((e, i) => ({ id: ids[i], ...e }));
|
|
||||||
|
|
||||||
// Broadcast and respond
|
// Validate inputs
|
||||||
saved.forEach(reading => broadcast('new-reading', reading));
|
if ([inDoor, inTemp, inHum, outDoor, outTemp, outHum].some(v => v === undefined)) {
|
||||||
|
return res.status(400).json({ error: 'Missing one of inbound/outbound fields' });
|
||||||
// Slack notification with both
|
|
||||||
if (slackWebhook) {
|
|
||||||
const textLines = saved.map(r =>
|
|
||||||
`Door *${r.dockDoor}* (${r.direction}) – Temp: ${r.temperature}°F, Humidity: ${r.humidity}%, HI: ${r.heatIndex}`
|
|
||||||
);
|
|
||||||
await axios.post(slackWebhook, { text: 'New dual readings:\n' + textLines.join('\n') });
|
|
||||||
}
|
}
|
||||||
res.json(saved);
|
|
||||||
|
// Compute heat indices
|
||||||
|
const hiIn = computeHeatIndex(inTemp, inHum);
|
||||||
|
const hiOut = computeHeatIndex(outTemp, outHum);
|
||||||
|
|
||||||
|
// Shift & period logic
|
||||||
|
const { shift, shiftStart, key, estNow } = getShiftInfo(new Date());
|
||||||
|
shiftCounters[key] = (shiftCounters[key] || 0) + 1;
|
||||||
|
const period = shiftCounters[key];
|
||||||
|
|
||||||
|
// Prepare EST timestamps
|
||||||
|
const sqlTimestamp = formatDateEST(estNow);
|
||||||
|
const broadcastTimestamp = isoStringEST(estNow);
|
||||||
|
|
||||||
|
// Insert readings into DB
|
||||||
|
const insertSQL = `
|
||||||
|
INSERT INTO readings
|
||||||
|
(dockDoor, direction, timestamp, temperature, humidity, heatIndex)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
await pool.execute(insertSQL, [inDoor, 'inbound', sqlTimestamp, inTemp, inHum, hiIn]);
|
||||||
|
await pool.execute(insertSQL, [outDoor, 'outbound', sqlTimestamp, outTemp, outHum, hiOut]);
|
||||||
|
|
||||||
|
// Broadcast via SSE (EST)
|
||||||
|
broadcast('new-reading', {
|
||||||
|
dockDoor: inDoor,
|
||||||
|
direction: 'inbound',
|
||||||
|
timestamp: broadcastTimestamp,
|
||||||
|
temperature: inTemp,
|
||||||
|
humidity: inHum,
|
||||||
|
heatIndex: hiIn
|
||||||
|
});
|
||||||
|
broadcast('new-reading', {
|
||||||
|
dockDoor: outDoor,
|
||||||
|
direction: 'outbound',
|
||||||
|
timestamp: broadcastTimestamp,
|
||||||
|
temperature: outTemp,
|
||||||
|
humidity: outHum,
|
||||||
|
heatIndex: hiOut
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate and upload today's CSV, get URL
|
||||||
|
const dateKey = `${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`
|
||||||
|
);
|
||||||
|
let csvUrl = null;
|
||||||
|
try {
|
||||||
|
csvUrl = await uploadTrendsCsv(dateKey, rows);
|
||||||
|
} catch (uploadErr) {
|
||||||
|
console.error('Failed to upload CSV to S3:', uploadErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat JSON Slack payload with CSV URL
|
||||||
|
const slackPayload = {
|
||||||
|
text: 'New temperature readings recorded',
|
||||||
|
inbound_dockDoor: inDoor,
|
||||||
|
inbound_temperature: inTemp,
|
||||||
|
inbound_humidity: inHum,
|
||||||
|
hiIn,
|
||||||
|
outbound_dockDoor: outDoor,
|
||||||
|
outbound_temperature: outTemp,
|
||||||
|
outbound_humidity: outHum,
|
||||||
|
hiOut,
|
||||||
|
shift,
|
||||||
|
period,
|
||||||
|
timestamp: broadcastTimestamp,
|
||||||
|
csvUrl // include the CSV download URL
|
||||||
|
};
|
||||||
|
console.log('🛠️ Slack payload:', slackPayload);
|
||||||
|
await axios.post(process.env.SLACK_WEBHOOK_URL, slackPayload);
|
||||||
|
|
||||||
|
res.json({ success: true, shift, period, csvUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving readings or sending Slack:', err);
|
console.error('❌ POST /api/readings error:', err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Return all readings
|
||||||
app.get('/api/readings', async (req, res) => {
|
app.get('/api/readings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = await db('readings').orderBy('timestamp', 'asc');
|
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('❌ GET /api/readings error:', err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Export CSV of all readings
|
||||||
app.get('/api/export', async (req, res) => {
|
app.get('/api/export', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = await db('readings').orderBy('timestamp', 'asc');
|
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
|
||||||
res.setHeader('Content-disposition', 'attachment; filename=readings.csv');
|
res.setHeader('Content-disposition', 'attachment; filename=readings.csv');
|
||||||
res.set('Content-Type', 'text/csv');
|
res.set('Content-Type', 'text/csv');
|
||||||
res.write('id,dockDoor,direction,timestamp,temperature,humidity,heatIndex\n');
|
res.write('id,dockDoor,direction,timestamp,temperature,humidity,heatIndex\n');
|
||||||
rows.forEach(r =>
|
rows.forEach(r => {
|
||||||
res.write(`${r.id},${r.dockDoor},${r.direction},${r.timestamp},${r.temperature},${r.humidity},${r.heatIndex}\n`)
|
const ts = (r.timestamp instanceof Date) ? formatDateEST(r.timestamp) : r.timestamp;
|
||||||
);
|
res.write(`${r.id},${r.dockDoor},${r.direction},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`);
|
||||||
|
});
|
||||||
res.end();
|
res.end();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('❌ GET /api/export error:', err);
|
||||||
res.status(500).send(err.message);
|
res.status(500).send(err.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user