This commit is contained in:
joshbaney 2025-04-22 09:41:26 -04:00
parent 8738371bbb
commit 3c26875e5e
12 changed files with 771 additions and 303 deletions

15
.env
View File

@ -3,11 +3,14 @@ PORT=3000
# MariaDB connection
DB_CLIENT=mysql
DB_HOST=localhost
DB_PORT=3306
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=warehouse_heatmap
DB_HOST=0.0.0.0
DB_PORT=3307
DB_USER=joshbaney
DB_PASSWORD=Ran0dal5!
DB_NAME=heatmap
# Slack Incoming Webhook URL
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXX/YYY/ZZZ
SLACK_WEBHOOK_URL=https://hooks.slack.com/triggers/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
AWS s3
S3_BUCKET_URL=https://s3.amazonaws.com/yourbucket/trends

18
Dockerfile Normal file
View 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
View File

@ -0,0 +1,32 @@
version: '3.8'
services:
db:
image: mariadb:10.11
restart: always
environment:
MYSQL_ROOT_PASSWORD: example_root_password
MYSQL_DATABASE: heat_tracker
MYSQL_USER: heat_user
MYSQL_PASSWORD: StrongP@ssw0rd
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:

View File

@ -1,16 +1,18 @@
{
"name": "warehouse-heatmap-app",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"mysql2": "^3.2.0",
"knex": "^2.4.2",
"body-parser": "^1.20.2",
"dotenv": "^16.0.0",
"axios": "^1.3.0"
}
"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.3.3",
"sqlite3": "^5.1.6"
}
}

View File

@ -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>
<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>
<div class="page-container">
<div class="diagram-container">
<!-- BINSstyle 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>
<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>

View File

@ -8,32 +8,32 @@
<title>Log Dual Readings</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>
<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 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>
<div class="page-container">
<h1>Enter Inbound & Outbound Readings</h1>
<form id="reading-form">
<fieldset>
<legend>Inbound</legend>
<input class="form-input" id="inboundDoor" type="number" placeholder="Dock Door # (124138,202209)" required>
<input class="form-input" id="inboundTemp" type="number" step="0.1" placeholder="Temperature (°F)" required>
<input class="form-input" id="inboundHum" type="number" step="0.1" placeholder="Humidity (%)" required>
</fieldset>
<fieldset>
<legend>Outbound</legend>
<input class="form-input" id="outboundDoor" type="number" placeholder="Dock Door # (142201)" required>
<input class="form-input" id="outboundTemp" type="number" step="0.1" placeholder="Temperature (°F)" required>
<input class="form-input" id="outboundHum" type="number" step="0.1" placeholder="Humidity (%)" required>
</fieldset>
<button type="submit" class="big-button">Submit Both</button>
</form>
</div>
<script src="scripts/input.js"></script>
<div class="page-container">
<h1>Enter Inbound & Outbound Readings</h1>
<form id="reading-form">
<fieldset>
<legend>Inbound</legend>
<input class="form-input" id="inboundDoor" type="number" placeholder="Dock Door # (124138,202209)" required>
<input class="form-input" id="inboundTemp" type="number" step="0.1" placeholder="Temperature (°F)" required>
<input class="form-input" id="inboundHum" type="number" step="0.1" placeholder="Humidity (%)" required>
</fieldset>
<fieldset>
<legend>Outbound</legend>
<input class="form-input" id="outboundDoor" type="number" placeholder="Dock Door # (142201)" required>
<input class="form-input" id="outboundTemp" type="number" step="0.1" placeholder="Temperature (°F)" required>
<input class="form-input" id="outboundHum" type="number" step="0.1" placeholder="Humidity (%)" required>
</fieldset>
<button type="submit" class="big-button">Submit Both</button>
</form>
</div>
<script src="scripts/input.js"></script>
</body>
</html>

View File

@ -1,51 +1,129 @@
// door ranges
// dockdoor 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),
];
...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 heatindex formula
function getColorFromHI(H) {
const pct = Math.min(Math.max((H - 70) / 30, 0), 1);
const r = 255;
const g = Math.round(255 * (1 - pct));
return `rgba(${r},${g},0,0.8)`;
}
// store readings per door
const doorData = {};
function createGrid() {
const row = document.getElementById('dock-row');
doors.forEach(d => {
const sq = document.createElement('div');
sq.className = 'dock-square';
sq.dataset.door = d;
row.appendChild(sq);
// color by heat index
function getColorFromHI(H) {
const pct = Math.min(Math.max((H - 70) / 30, 0), 1);
const r = 255, g = Math.round(255 * (1 - pct));
return `rgba(${r},${g},0,0.8)`;
}
// 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);
});
}
// 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) sq.style.background = getColorFromHI(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);
});
}
// realtime 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';
});
}
// apply a new reading to its square
function colorize(door, hi) {
const sq = document.querySelector(`.dock-square[data-door="${door}"]`);
if (!sq) return;
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));
// subscribe SSE
const es = new EventSource('/api/stream');
es.addEventListener('new-reading', e => {
const { dockDoor, heatIndex } = JSON.parse(e.data);
colorize(dockDoor, heatIndex);
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';
});
});
}
document.addEventListener('DOMContentLoaded', init);
// 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();
});

View File

@ -1,40 +1,109 @@
const ctxD = document.getElementById('dailyChart').getContext('2d');
const ctxW = document.getElementById('weeklyChart').getContext('2d');
const ctxM = document.getElementById('monthlyChart').getContext('2d');
async function drawTrend(ctx, period) {
const all = await fetch('/api/readings').then(r=>r.json());
// group by day/week/month
const groups = {};
all.forEach(r => {
const d = new Date(r.timestamp);
let key;
if (period==='daily') key = d.toISOString().slice(0,10);
if (period==='weekly') key = `${d.getFullYear()}-W${Math.ceil(d.getDate()/7)}`;
if (period==='monthly') key = d.toISOString().slice(0,7);
groups[key] = groups[key]||[];
groups[key].push(r.heatIndex);
});
const labels = Object.keys(groups);
const data = labels.map(k => {
const arr = groups[k];
return {
max: Math.max(...arr),
min: Math.min(...arr),
avg: arr.reduce((a,b)=>a+b,0)/arr.length
};
});
new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{ label: 'Max', data: data.map(x=>x.max) },
{ label: 'Avg', data: data.map(x=>x.avg) },
{ label: 'Min', data: data.map(x=>x.min) }
]
}
});
// average helper
function average(arr) {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
['daily','weekly','monthly'].forEach((p,i)=>drawTrend([ctxD,ctxW,ctxM][i], p));
// period definitions
const periodKeys = ['hourly','daily','weekly','monthly','yearly'];
const periodLabels = ['Hourly','Daily','Weekly','Monthly','Yearly'];
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;
// fetch all readings
async function fetchReadings() {
const res = await fetch('/api/readings');
return res.ok ? res.json() : [];
}
// build 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 and compute 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, // <<— ensure the canvas fills its container
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 }
}
}
});
}
}
document.addEventListener('DOMContentLoaded', () => {
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));
});

View File

@ -1,63 +1,176 @@
/* 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 {
height: 40px;
margin-right: 1rem;
}
.main-header .header-title {
color: #fff;
font-size: 1.2rem;
font-weight: bold;
margin-right: auto;
}
.main-header .nav-btn {
background-color: #FF9900;
color: #111;
border: none;
border-radius: 5px;
padding: 0.5rem 1rem;
margin-left: 0.5rem;
cursor: pointer;
font-weight: bold;
}
.main-header .nav-btn:hover {
background-color: #e48f00;
}
.page-container {
margin-top: 70px;
width: 95%; margin: 0 auto;
padding: 1rem;
background-color: #fff;
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; }
background-color: #232F3E;
display: flex;
align-items: center;
padding: 0.5rem 2rem;
position: fixed;
top: 0; left: 0; right: 0;
z-index: 1000;
}
.logo {
height: 40px;
margin-right: 1rem;
}
.header-title {
color: #fff;
font-size: 1.2rem;
font-weight: bold;
margin-right: auto;
}
.nav-btn {
background-color: #FF9900;
color: #111;
border: none;
border-radius: 5px;
padding: 0.5rem 1rem;
margin-left: 0.5rem;
cursor: pointer;
font-weight: bold;
}
.nav-btn:hover {
background-color: #e48f00;
}
/* 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; }
/* ================ 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;
}
/* Existing element styles */
body { font-family: Arial, sans-serif; margin: 20px; }
/* ================ 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;
}
.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;
}
/* ================ 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;
}
/* ================ 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;
}

View File

@ -2,26 +2,52 @@
<html lang="en">
<head>
<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">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="stylesheet" href="styles.css">
<title>| Fuego - Heat Tracker</title>
<title>Trend Graph</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>
<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 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>
<div class="page-container">
<canvas id="dailyChart"></canvas>
<canvas id="weeklyChart"></canvas>
<canvas id="monthlyChart"></canvas>
<div class="page-container">
<h1>Trend Analysis</h1>
<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;">
<!-- temperature -->
<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>
<!-- humidity -->
<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>
<!-- heat index -->
<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>
<script src="scripts/trends.js"></script>
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
</div>
<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>
<script src="scripts/trends.js"></script>
</body>
</html>

30
s3.js Normal file
View 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 };

248
server.js
View File

@ -1,128 +1,214 @@
// server.js
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');
const knex = require('knex');
const axios = require('axios');
// 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 express = require('express');
const mysql = require('mysql2/promise');
const bodyParser = require('body-parser');
const path = require('path');
const axios = require('axios');
const { uploadTrendsCsv } = require('./s3');
const app = express();
const PORT = process.env.PORT || 3000;
const slackWebhook = process.env.SLACK_WEBHOOK_URL;
// Create table if not exists, now with direction
// Inmemory shift counters
const shiftCounters = {};
// 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,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
connectTimeout: 10000 // give it up to 10s before timing out
});
// Ensure readings table exists
(async () => {
if (!await db.schema.hasTable('readings')) {
await db.schema.createTable('readings', table => {
table.increments('id').primary();
table.integer('dockDoor');
table.string('direction');
table.timestamp('timestamp');
table.float('temperature');
table.float('humidity');
table.float('heatIndex');
});
}
const createSQL = `
CREATE TABLE IF NOT EXISTS readings (
id INT AUTO_INCREMENT PRIMARY KEY,
dockDoor INT NOT NULL,
timestamp DATETIME NOT NULL,
temperature DOUBLE,
humidity DOUBLE,
heatIndex DOUBLE
);
`;
await pool.execute(createSQL);
})();
// Simple SSE setup
let clients = [];
app.get('/api/stream', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
});
res.flushHeaders();
clients.push(res);
req.on('close', () => {
clients = clients.filter(c => c !== res);
});
});
function broadcast(event, data) {
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
clients.forEach(c => c.write(msg));
}
// 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 = -5.481717e-2;
const c7 = 1.22874e-3, c8 = 8.5282e-4, c9 = -1.99e-6;
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 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 direction based on door number
function getDirection(door) {
door = Number(door);
if (door >= 124 && door <= 138) return 'Inbound';
if (door >= 142 && door <= 201) return 'Outbound';
if (door >= 202 && door <= 209) return 'Inbound';
return 'Unknown';
// 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 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')));
let clients = [];
app.get('/api/stream', (req, res) => {
res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
res.flushHeaders();
clients.push(res);
req.on('close', () => { clients = clients.filter(c => c !== res); });
});
function broadcast(event, data) {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
clients.forEach(res => res.write(payload));
}
// Dualreading POST endpoint
app.post('/api/readings', async (req, res) => {
try {
const { inbound, outbound } = req.body; // each: {dockDoor,temperature,humidity}
const timestamp = new Date();
const entries = [inbound, outbound].map(r => {
const direction = getDirection(r.dockDoor);
const heatIndex = computeHeatIndex(r.temperature, r.humidity);
return { ...r, direction, timestamp, heatIndex };
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];
// Insert both readings with serverside NOW()
const insertSQL = `
INSERT INTO readings
(dockDoor, timestamp, temperature, humidity, heatIndex)
VALUES (?, NOW(), ?, ?, ?)
`;
await pool.execute(insertSQL, [inbound.dockDoor, inbound.temperature, inbound.humidity, hiIn]);
await pool.execute(insertSQL, [outbound.dockDoor, outbound.temperature, outbound.humidity, hiOut]);
// Broadcast to SSE clients (use UTC ISO for frontend)
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'
});
// Insert both
const ids = await db('readings').insert(entries);
const saved = entries.map((e, i) => ({ id: ids[i], ...e }));
// Broadcast and respond
saved.forEach(reading => broadcast('new-reading', reading));
// Base Slack text
let slackText =
`${shift} Shift ${period} temperature checks for ${estString} EST
// 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') });
Inbound Dock Door: ${inbound.dockDoor}
Temp: ${inbound.temperature}°F
Humidity: ${inbound.humidity}%
Heat Index: ${hiIn}
Outbound Dock Door: ${outbound.dockDoor}
Temp: ${outbound.temperature}°F
Humidity: ${outbound.humidity}%
Heat Index: ${hiOut}`;
// 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);
}
res.json(saved);
// Send Slack notification
await axios.post(process.env.SLACK_WEBHOOK_URL, { text: slackText });
res.json({ success: true, shift, period });
} 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 });
}
});
// Return all readings as JSON
app.get('/api/readings', async (req, res) => {
try {
const rows = await db('readings').orderBy('timestamp', 'asc');
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 });
}
});
// Export CSV of all readings
app.get('/api/export', async (req, res) => {
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.set('Content-Type', 'text/csv');
res.write('id,dockDoor,direction,timestamp,temperature,humidity,heatIndex\n');
rows.forEach(r =>
res.write(`${r.id},${r.dockDoor},${r.direction},${r.timestamp},${r.temperature},${r.humidity},${r.heatIndex}\n`)
);
res.write('id,dockDoor,timestamp,temperature,humidity,heatIndex\n');
rows.forEach(r => {
// 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) {
console.error('GET /api/export error:', err);
res.status(500).send(err.message);
}
});
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));
// push goddamn it!
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});