diff --git a/public/scripts/heatmap.js b/public/scripts/heatmap.js
new file mode 100644
index 0000000..f0b3d27
--- /dev/null
+++ b/public/scripts/heatmap.js
@@ -0,0 +1,199 @@
+// public/scripts/heatmap.js
+
+// ==== Configuration ====
+const dockDoors = [
+ ...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),
+];
+
+// Map region names → element IDs
+const regionEls = {
+ 'A-Mod': document.getElementById('region-amod'),
+ 'Outbound Pre-Slam':document.getElementById('region-preslam'),
+ 'B-Mod': document.getElementById('region-bmod'),
+};
+
+// ==== State Stores ====
+const doorData = {}; // dockDoor → array of readings
+const regionData = {}; // regionName → array of readings
+
+// Initialize data arrays
+dockDoors.forEach(d => doorData[d] = []);
+Object.keys(regionEls).forEach(r => regionData[r] = []);
+
+// ==== Color Helper ====
+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.7)`;
+}
+
+// ==== Stats Helper ====
+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 };
+}
+
+// ==== Grid Creation ====
+function createGrid() {
+ const row = document.getElementById('dock-row');
+ dockDoors.forEach(d => {
+ const sq = document.createElement('div');
+ sq.className = 'dock-square';
+ sq.dataset.door = d;
+ sq.textContent = d;
+ row.appendChild(sq);
+ });
+}
+
+// ==== Initial Load ====
+async function loadInitial() {
+ const all = await fetch('/api/readings').then(r=>r.json());
+ all.forEach(r => {
+ if (r.dockDoor != null) {
+ doorData[r.dockDoor].push(r);
+ } else if (r.region) {
+ regionData[r.region].push(r);
+ }
+ });
+ // Color each dock
+ Object.entries(doorData).forEach(([d, arr]) => {
+ const stats = computeStats(arr);
+ if (stats) {
+ const el = document.querySelector(`.dock-square[data-door="${d}"]`);
+ el.style.background = getColorFromHI(stats.latest.heatIndex);
+ }
+ });
+ // Color each region
+ Object.entries(regionEls).forEach(([r, el]) => {
+ const stats = computeStats(regionData[r]);
+ if (stats) el.style.background = getColorFromHI(stats.latest.heatIndex);
+ });
+}
+
+// ==== SSE Subscriptions ====
+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);
+ const el = document.querySelector(`.dock-square[data-door="${r.dockDoor}"]`);
+ el.style.background = getColorFromHI(r.heatIndex);
+ }
+ });
+
+ es.addEventListener('new-area-reading', e => {
+ const r = JSON.parse(e.data);
+ if (regionData[r.region]) {
+ regionData[r.region].push(r);
+ const el = regionEls[r.region];
+ el.style.background = getColorFromHI(r.heatIndex);
+ }
+ });
+}
+
+// ==== Tooltip Setup ====
+function setupTooltips() {
+ const tooltip = document.getElementById('tooltip');
+
+ // Common hover handlers
+ function showTip(e, infoHtml) {
+ tooltip.innerHTML = infoHtml;
+ tooltip.style.display = 'block';
+ }
+ function moveTip(e) {
+ tooltip.style.top = `${e.clientY + 10}px`;
+ tooltip.style.left = `${e.clientX + 10}px`;
+ }
+ function hideTip() {
+ tooltip.style.display = 'none';
+ }
+
+ // Dock squares
+ document.querySelectorAll('.dock-square').forEach(sq => {
+ sq.addEventListener('mouseenter', e => {
+ const d = Number(sq.dataset.door);
+ const stats = computeStats(doorData[d]);
+ if (!stats) return;
+ const lt = new Date(stats.latest.timestamp).toLocaleString();
+ const ht = new Date(stats.max.timestamp).toLocaleString();
+ const lt2= new Date(stats.min.timestamp).toLocaleString();
+ const html = `
+ Door ${d}
+ Latest: HI ${stats.latest.heatIndex}, T ${stats.latest.temperature}°F, H ${stats.latest.humidity}%
+ Time: ${lt}
+ Max: HI ${stats.max.heatIndex} at ${ht}
+ Min: HI ${stats.min.heatIndex} at ${lt2}
+ Avg HI: ${stats.avg}
+ `;
+ showTip(e, html);
+ });
+ sq.addEventListener('mousemove', moveTip);
+ sq.addEventListener('mouseleave', hideTip);
+ });
+
+ // Regions
+ Object.entries(regionEls).forEach(([r, el]) => {
+ el.addEventListener('mouseenter', e => {
+ const stats = computeStats(regionData[r]);
+ if (!stats) return;
+ const lr = stats.latest;
+ const html = `
+ ${r}
+ Latest Station: ${lr.stationCode}
+ HI ${lr.heatIndex}, T ${lr.temperature}°F, H ${lr.humidity}%
+ Time: ${new Date(lr.timestamp).toLocaleString()}
+ Max HI: ${stats.max.heatIndex}, Min HI: ${stats.min.heatIndex}
+ Avg HI: ${stats.avg}
+ `;
+ showTip(e, html);
+ });
+ el.addEventListener('mousemove', moveTip);
+ el.addEventListener('mouseleave', hideTip);
+ });
+}
+
+// ==== Zoom & Pan via CSS Vars ====
+function setupZoom() {
+ const root = document.documentElement.style;
+ let scale = 1;
+ const init = {
+ 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', `${init.size * scale}px`);
+ root.setProperty('--gap', `${init.gap * scale}px`);
+ root.setProperty('--v-gap', `${init.vgap * scale}px`);
+ root.setProperty('--warehouse-height',`${init.wh * scale}px`);
+ }
+ });
+}
+
+// ==== Init ====
+document.addEventListener('DOMContentLoaded', () => {
+ createGrid();
+ loadInitial();
+ subscribeRealtime();
+ setupTooltips();
+ setupZoom();
+});
diff --git a/public/scripts/input-area.js b/public/scripts/input-area.js
new file mode 100644
index 0000000..8c1d9e9
--- /dev/null
+++ b/public/scripts/input-area.js
@@ -0,0 +1,40 @@
+// public/scripts/input-area.js
+
+document.addEventListener('DOMContentLoaded', () => {
+ const form = document.getElementById('area-form');
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ // Gather form values
+ const area = document.getElementById('area').value;
+ const stationCode = document.getElementById('stationCode').value;
+ const temperature = parseFloat(document.getElementById('temperature').value);
+ const humidity = parseFloat(document.getElementById('humidity').value);
+
+ // Build payload
+ const payload = { area, stationCode, temperature, humidity };
+
+ try {
+ const res = await fetch('/api/area-readings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (!res.ok) {
+ const { error } = await res.json();
+ alert(`Error: ${error}`);
+ return;
+ }
+
+ // Success: reset form and optionally notify user
+ form.reset();
+ alert('Area reading submitted successfully!');
+ } catch (err) {
+ console.error('Network error submitting area reading:', err);
+ alert('Network error – please try again.');
+ }
+ });
+ });
+
\ No newline at end of file
diff --git a/public/scripts/input.js b/public/scripts/input.js
new file mode 100644
index 0000000..7936441
--- /dev/null
+++ b/public/scripts/input.js
@@ -0,0 +1,29 @@
+const form = document.getElementById('reading-form');
+const inDoor = document.getElementById('inboundDoor');
+const outDoor = document.getElementById('outboundDoor');
+const inTemp = document.getElementById('inboundTemp');
+const outTemp = document.getElementById('outboundTemp');
+const inHum = document.getElementById('inboundHum');
+const outHum = document.getElementById('outboundHum');
+
+// Auto-set direction fields (readonly) if you want display
+// omitted here since direction hidden in dual-input
+
+form.addEventListener('submit', e => {
+ e.preventDefault();
+ const payload = {
+ inbound: {
+ dockDoor: +inDoor.value,
+ temperature: +inTemp.value,
+ humidity: +inHum.value
+ },
+ outbound: {
+ dockDoor: +outDoor.value,
+ temperature: +outTemp.value,
+ humidity: +outHum.value
+ }
+ };
+ fetch('/api/readings', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
+ }).then(res => res.json()).then(() => form.reset());
+});
\ No newline at end of file
diff --git a/public/scripts/trends.js b/public/scripts/trends.js
new file mode 100644
index 0000000..6dca130
--- /dev/null
+++ b/public/scripts/trends.js
@@ -0,0 +1,161 @@
+// average helper
+function average(arr) {
+ return arr.reduce((a, b) => a + b, 0) / arr.length;
+}
+
+// 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 },
+ 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:'Europe/London' });
+ const dir = getDirection(r.dockDoor);
+ tbody.append(`
+