This commit is contained in:
joshbaney 2025-04-23 07:30:14 -04:00
parent 3c26875e5e
commit 697eb80d9c
8 changed files with 385 additions and 186 deletions

13
.env
View File

@ -9,8 +9,13 @@ DB_USER=joshbaney
DB_PASSWORD=Ran0dal5!
DB_NAME=heatmap
# Slack Incoming Webhook URL
SLACK_WEBHOOK_URL=https://hooks.slack.com/triggers/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
#AWS s3
S3_BUCKET_URL=https://s3.amazonaws.com/bwi2temps/trends
AWS s3
S3_BUCKET_URL=https://s3.amazonaws.com/yourbucket/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/

View File

@ -1,18 +1,16 @@
# Use official Node.js 18 LTS alpine image
# Dockerfile
FROM node:18-alpine
# Create app directory
WORKDIR /app
WORKDIR /usr/src/app
# Install app dependencies
# Install dependencies
COPY package*.json ./
RUN npm install --production
# Copy source
# Bundle app source
COPY . .
# Expose port
# Expose port and run
EXPOSE 3000
# Start the server
CMD ["npm", "start"]
CMD ["node", "server.js"]

View File

@ -1,32 +1,13 @@
# docker-compose.yaml
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:
fuego-app:
build: .
restart: always
env_file: .env # your DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SLACK_WEBHOOK_URL, etc.
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:
- .:/usr/src/app # live code reload; remove in prod if undesired
restart: unless-stopped
# no depends_on here since the DB lives elsewhere

71
public/index.html Normal file
View 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>

View File

@ -37,3 +37,41 @@
<script src="scripts/input.js"></script>
</body>
</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>

View File

@ -3,9 +3,11 @@ function average(arr) {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
// period definitions
// interval mappings
const periodKeys = ['hourly','daily','weekly','monthly','yearly'];
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 },
@ -19,22 +21,62 @@ const periodConfig = {
};
// global Chart.js instance
let trendChart;
let trendChart, dataTable;
// fetch all readings
// fetch readings from server
async function fetchReadings() {
const res = await fetch('/api/readings');
return res.ok ? res.json() : [];
}
// build or update chart
// 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 and compute stats
// group & stats
const groups = {};
all.forEach(r => {
const key = cfg.keyFn(r);
@ -74,10 +116,13 @@ async function drawTrend() {
} else {
trendChart = new Chart(ctx, {
type: 'line',
data: { labels: labels.map(cfg.labelFn), datasets },
data: {
labels: labels.map(cfg.labelFn),
datasets
},
options: {
responsive: true,
maintainAspectRatio: false, // <<— ensure the canvas fills its container
maintainAspectRatio: false,
plugins: {
legend: { display: true },
tooltip: { mode:'index', intersect:false },
@ -93,16 +138,23 @@ async function drawTrend() {
}
});
}
// 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));

View File

@ -1,15 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="styles.css">
<title>Trend Graph</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="styles.css" />
<!-- DataTables CSS for table styling, sorting arrows, etc. -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css" />
<title>Trend Graph & Table</title>
</head>
<body>
<header class="main-header">
<img src="/image/logo.png" class="logo" alt="Amazon Logo">
<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>
@ -21,33 +23,56 @@
<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">
<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>
<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 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>
</body>
</html>

227
server.js
View File

@ -10,11 +10,60 @@ const { uploadTrendsCsv } = require('./s3');
const app = express();
const PORT = process.env.PORT || 3000;
// Inmemory shift counters
// In-memory shift counters
const shiftCounters = {};
// Create MariaDB connection pool
// server.js (pool section)
// Helper: format a Date in EST as SQL DATETIME string
function formatDateEST(date) {
const pad = n => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
// 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)
function computeHeatIndex(T, R) {
const [c1,c2,c3,c4,c5,c6,c7,c8,c9] =
[-42.379,2.04901523,10.14333127,-0.22475541,-0.00683783,
-0.05481717,0.00122874,0.00085282,-0.00000199];
const HI = c1 + c2*T + c3*R + c4*T*R
+ c5*T*T + c6*R*R + c7*T*T*R
+ c8*T*R*R + c9*T*T*R*R;
return Math.round(HI * 100) / 100;
}
// Determine shift info in EST
function getShiftInfo(now) {
const estNow = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
const [h,m] = [estNow.getHours(), estNow.getMinutes()];
let shift, shiftStart = new Date(estNow);
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);
}
const key = `${shift}-${shiftStart.toISOString().slice(0,10)}-` +
`${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,
@ -24,16 +73,18 @@ const pool = mysql.createPool({
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
connectTimeout: 10000 // give it up to 10s before timing out
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,
@ -43,7 +94,7 @@ const pool = mysql.createPool({
await pool.execute(createSQL);
})();
// Simple SSE setup
// SSE setup
let clients = [];
app.get('/api/stream', (req, res) => {
res.set({
@ -53,137 +104,117 @@ app.get('/api/stream', (req, res) => {
});
res.flushHeaders();
clients.push(res);
req.on('close', () => {
clients = clients.filter(c => c !== res);
});
req.on('close', () => { clients = clients.filter(c => c !== res); });
});
function broadcast(event, data) {
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=-0.05481717;
const c7=0.00122874, c8=0.00085282, c9=-0.00000199;
const HI = c1 + c2*T + c3*R + c4*T*R
+ c5*T*T + c6*R*R + c7*T*T*R
+ c8*T*R*R + c9*T*T*R*R;
return Math.round(HI * 100) / 100;
}
// Determine shift info in EST
function getShiftInfo(now) {
// Convert to EST
const estNow = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
const h = estNow.getHours(), m = estNow.getMinutes();
let shift, shiftStart;
if (h > 7 || (h === 7 && m >= 0)) {
if (h < 17 || (h === 17 && m < 30)) {
shift = 'Day';
shiftStart = new Date(estNow);
shiftStart.setHours(7,0,0,0);
} else {
shift = 'Night';
shiftStart = new Date(estNow);
shiftStart.setHours(17,30,0,0);
}
} else {
shift = 'Night';
shiftStart = new Date(estNow);
shiftStart.setDate(shiftStart.getDate() - 1);
shiftStart.setHours(17,30,0,0);
}
const 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')));
// Dualreading POST endpoint
// Dual-reading POST endpoint
app.post('/api/readings', async (req, res) => {
try {
const { inbound, outbound } = req.body;
const hiIn = computeHeatIndex(inbound.temperature, inbound.humidity);
const hiOut = computeHeatIndex(outbound.temperature, outbound.humidity);
console.log('🔔 POST /api/readings body:', req.body);
const { inbound = {}, outbound = {} } = req.body;
const { dockDoor: inDoor, temperature: inTemp, humidity: inHum } = inbound;
const { dockDoor: outDoor, temperature: outTemp, humidity: outHum } = outbound;
// Validate inputs
if ([inDoor, inTemp, inHum, outDoor, outTemp, outHum].some(v => v === undefined)) {
return res.status(400).json({ error: 'Missing one of inbound/outbound fields' });
}
// 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];
// Insert both readings with serverside NOW()
// Prepare EST timestamps
const sqlTimestamp = formatDateEST(estNow);
const broadcastTimestamp = isoStringEST(estNow);
// Insert readings into DB
const insertSQL = `
INSERT INTO readings
(dockDoor, timestamp, temperature, humidity, heatIndex)
VALUES (?, NOW(), ?, ?, ?)
(dockDoor, direction, timestamp, temperature, humidity, heatIndex)
VALUES (?, ?, ?, ?, ?, ?)
`;
await pool.execute(insertSQL, [inbound.dockDoor, inbound.temperature, inbound.humidity, hiIn]);
await pool.execute(insertSQL, [outbound.dockDoor, outbound.temperature, outbound.humidity, hiOut]);
await pool.execute(insertSQL, [inDoor, 'inbound', sqlTimestamp, inTemp, inHum, hiIn]);
await pool.execute(insertSQL, [outDoor, 'outbound', sqlTimestamp, outTemp, outHum, 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'
// 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
});
// Base Slack text
let slackText =
`${shift} Shift ${period} temperature checks for ${estString} EST
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')}`;
// 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 {
const publicUrl = await uploadTrendsCsv(dateKey, rows);
slackText += `\n\nDownload daily trends: ${publicUrl}`;
csvUrl = await uploadTrendsCsv(dateKey, rows);
} catch (uploadErr) {
console.error('Failed to upload CSV to S3:', uploadErr);
}
// Send Slack notification
await axios.post(process.env.SLACK_WEBHOOK_URL, { text: slackText });
// 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 });
res.json({ success: true, shift, period, csvUrl });
} catch (err) {
console.error('POST /api/readings error:', err);
console.error('POST /api/readings error:', err);
res.status(500).json({ error: err.message });
}
});
// Return all readings as JSON
// Return all readings
app.get('/api/readings', async (req, res) => {
try {
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
res.json(rows);
} catch (err) {
console.error('GET /api/readings error:', err);
console.error('GET /api/readings error:', err);
res.status(500).json({ error: err.message });
}
});
@ -194,21 +225,19 @@ app.get('/api/export', async (req, res) => {
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`);
res.setHeader('Content-disposition', 'attachment; filename=readings.csv');
res.set('Content-Type', 'text/csv');
res.write('id,dockDoor,timestamp,temperature,humidity,heatIndex\n');
res.write('id,dockDoor,direction,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`);
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();
} catch (err) {
console.error('GET /api/export error:', err);
console.error('GET /api/export error:', err);
res.status(500).send(err.message);
}
});
// Start server
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});