From 3c26875e5e310b6a2fc6c30832ff200a564a7856 Mon Sep 17 00:00:00 2001 From: joshbaney Date: Tue, 22 Apr 2025 09:41:26 -0400 Subject: [PATCH] docker --- .env | 15 ++- Dockerfile | 18 +++ docker-compose.yaml | 32 +++++ package.json | 32 ++--- public/heatmap.html | 35 ++++-- public/input.html | 52 ++++---- public/scripts/heatmap.js | 174 ++++++++++++++++++-------- public/scripts/trends.js | 145 ++++++++++++++++------ public/styles.css | 235 ++++++++++++++++++++++++++---------- public/trends.html | 58 ++++++--- s3.js | 30 +++++ server.js | 248 +++++++++++++++++++++++++------------- 12 files changed, 771 insertions(+), 303 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yaml create mode 100644 s3.js diff --git a/.env b/.env index f847706..a55592e 100644 --- a/.env +++ b/.env @@ -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 \ No newline at end of file +SLACK_WEBHOOK_URL=https://hooks.slack.com/triggers/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 + +AWS s3 +S3_BUCKET_URL=https://s3.amazonaws.com/your‐bucket/trends diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..26c51a8 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..f7f64bc --- /dev/null +++ b/docker-compose.yaml @@ -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: diff --git a/package.json b/package.json index 7b4219f..7e8fe9b 100644 --- a/package.json +++ b/package.json @@ -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" - } - } \ No newline at end of file + "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" + } +} diff --git a/public/heatmap.html b/public/heatmap.html index c2d9e75..b2b7460 100644 --- a/public/heatmap.html +++ b/public/heatmap.html @@ -5,24 +5,35 @@ - Warehouse Heat Map + Heat Map -
- - | Fuego - Heat Tracker - - - -
-
-
+ +
+ + | Fuego - Heat Tracker + + + +
+ +
+
+
+
+
- + - \ No newline at end of file + + + + + + + diff --git a/public/input.html b/public/input.html index bf64a1d..0a1b4dc 100644 --- a/public/input.html +++ b/public/input.html @@ -8,32 +8,32 @@ Log Dual Readings -
- - | Fuego - Heat Tracker - - - -
+
+ + | Fuego - Heat Tracker + + + +
-
-

Enter Inbound & Outbound Readings

-
-
- Inbound - - - -
-
- Outbound - - - -
- -
-
- +
+

Enter Inbound & Outbound Readings

+
+
+ Inbound + + + +
+
+ Outbound + + + +
+ +
+
+ \ No newline at end of file diff --git a/public/scripts/heatmap.js b/public/scripts/heatmap.js index 896f90a..2a97d17 100644 --- a/public/scripts/heatmap.js +++ b/public/scripts/heatmap.js @@ -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) { - 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)`; - } - - 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); + ...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), +]; + +// 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, 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); + }); +} + +// 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 = ` + Door ${d}
+ Latest: HI ${stats.latest.heatIndex}, T ${stats.latest.temperature}°F, H ${stats.latest.humidity}%
+ Time: ${new Date(stats.latest.timestamp).toLocaleString()}
+ Max: ${stats.max.heatIndex} at ${new Date(stats.max.timestamp).toLocaleString()}
+ Min: ${stats.min.heatIndex} at ${new Date(stats.min.timestamp).toLocaleString()}
+ 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`; }); - } - - document.addEventListener('DOMContentLoaded', init); - \ No newline at end of file + 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(); +}); diff --git a/public/scripts/trends.js b/public/scripts/trends.js index e54cdf1..8ad064d 100644 --- a/public/scripts/trends.js +++ b/public/scripts/trends.js @@ -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)); +}); diff --git a/public/styles.css b/public/styles.css index a466360..16e7755 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } - - /* 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; } - - /* Existing element styles */ - body { font-family: Arial, sans-serif; margin: 20px; } \ No newline at end of file + 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; +} + +/* ================ 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; +} + +/* ================ 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; +} diff --git a/public/trends.html b/public/trends.html index e6169f4..b0392ed 100644 --- a/public/trends.html +++ b/public/trends.html @@ -2,26 +2,52 @@ - + - - | Fuego - Heat Tracker + Trend Graph -
- - | Fuego - Heat Tracker - - - -
+
+ + | Fuego - Heat Tracker + + + +
-
- - - +
+

Trend Analysis

+ +
+ +
+ + + +
+ + + + + + + + + + + + +
- + +
+ +
+
+ + + + - \ No newline at end of file + diff --git a/s3.js b/s3.js new file mode 100644 index 0000000..8b7f136 --- /dev/null +++ b/s3.js @@ -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:///trends/.csv with public-read ACL. + * @param {string} key Date key like '20250423' + * @param {Array} 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 }; diff --git a/server.js b/server.js index 71a01ef..4dc0435 100644 --- a/server.js +++ b/server.js @@ -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 +// In‑memory 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)); -} - +// Dual‐reading 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 server‐side 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 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' }); - // 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! \ No newline at end of file +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +});