diff --git a/app/public/conditions.html b/app/public/conditions.html index 256e011..dbccd21 100644 --- a/app/public/conditions.html +++ b/app/public/conditions.html @@ -609,6 +609,12 @@ async function load() { return; } + // Station name (server may include it) + if (d.stationName) { + const nameEl = $('stationName'); + if (nameEl) nameEl.textContent = d.stationName; + } + // Header if (d.dateutc) { const dt = new Date(d.dateutc); diff --git a/app/public/dashboard.html b/app/public/dashboard.html index d6a1e65..c988e0a 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -110,6 +110,31 @@ a { color: var(--cool); text-decoration: none; } .progress-bar { height: 100%; background: var(--cool); border-radius: 3px; transition: width 0.5s; width: 0%; } .progress-text { font-size: 0.82rem; color: var(--ink-light); margin-top: 0.4rem; } .backfill-progress { margin-top: 0.75rem; } +/* Trend arrows */ +.stat-trend { font-size: 0.85rem; margin-left: 0.25rem; } +.trend-up { color: #e07848; } +.trend-down { color: #5ab0d8; } +.trend-flat { color: var(--ink-light); opacity: 0.5; } +/* Offline banner */ +.offline-banner { background: rgba(194,90,42,0.12); border: 1px solid var(--accent); border-radius: 10px; padding: 0.6rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; color: var(--accent); display: none; } +/* Monthly summary */ +.monthly-table-wrap { overflow-x: auto; margin-top: 0.75rem; } +.monthly-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } +.monthly-table th { text-align: left; padding: 0.4rem 0.75rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); border-bottom: 1px solid var(--border); } +.monthly-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; } +.monthly-table tr:last-child td { border-bottom: none; } +.monthly-table tr:hover td { background: var(--paper-darker); } +/* Heatmap */ +.heatmap-wrap { overflow-x: auto; padding-bottom: 0.5rem; } +.heatmap { display: inline-grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); gap: 3px; } +.heatmap-cell { width: 14px; height: 14px; border-radius: 3px; background: var(--border); position: relative; } +.heatmap-cell[title]:hover::after { content: attr(title); position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); color: #fff; padding: 3px 7px; border-radius: 5px; font-size: 11px; white-space: nowrap; pointer-events: none; z-index: 10; } +.heatmap-legend { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.6rem; font-size: 0.72rem; color: var(--ink-light); } +.heatmap-legend-swatch { width: 12px; height: 12px; border-radius: 2px; } +/* Settings sections */ +.settings-section-hdr { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-light); margin: 1.25rem 0 0.6rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--border); } +.threshold-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } +@media (max-width: 480px) { .threshold-row { grid-template-columns: 1fr; } } @@ -127,23 +152,25 @@ a { color: var(--cool); text-decoration: none; }
+
+
-
Temperature
—
°F
+
Temperature
—
°F
Feels Like
—
°F
-
Humidity
—
%
-
Dew Point
—
°F
-
Wind
—
mph
+
Humidity
—
%
+
Dew Point
—
°F
+
Wind
—
mph
Wind Gust
—
mph
Pressure
-
—
+
—
inHg  
Rain Today
—
in
Rain Rate
—
in/hr
-
UV Index
—
-
Solar Rad.
—
W/m²
+
UV Index
—
+
Solar Rad.
—
W/m²
Battery
—
@@ -191,6 +218,22 @@ a { color: var(--cool); text-decoration: none; }
+ +
+ Monthly Summary +
+
Loading…
+
+
+ + +
+ Temperature Calendar +
+
Loading…
+
+
+
Station API Keys @@ -256,6 +299,45 @@ a { color: var(--cool); text-decoration: none; }
+ +
+ +
Alert Thresholds (Station)
+

Leave blank to disable. These extend your existing push/ntfy alert subscriptions.

+
+
+
+
+
+
+
+ + +
+ +
+ +
Station Display
+
+ + +
+
+ + +
+ +
+ +
Change Password
+
+
+
+
+
+ + +
@@ -599,6 +681,7 @@ async function updateLatest() { if (meta && d.dateutc) { meta.textContent = 'Updated ' + new Date(d.dateutc).toLocaleTimeString(); } + updateOfflineBanner(d.dateutc || null); } catch {} } @@ -871,16 +954,209 @@ function updateExportLink() { document.getElementById('exportRange')?.addEventListener('change', updateExportLink); updateExportLink(); +// ── Trend arrows ───────────────────────────────────────────────────────────── +async function updateTrends() { + try { + const r = await fetch('/api/station/trend'); + if (!r.ok) return; + const t = await r.json(); + const arrows = { up: '▲', down: '▼', flat: '–' }; + const cls = { up: 'trend-up', down: 'trend-down', flat: 'trend-flat' }; + const map = { tempf: 'trTempF', humidity: 'trHum', dewPoint: 'trDew', windspeedmph: 'trWind', baromrelin: 'trPres', uv: 'trUV', solarradiation: 'trSolar' }; + for (const [key, elId] of Object.entries(map)) { + const el = document.getElementById(elId); + if (!el || !t[key]) continue; + el.textContent = arrows[t[key]] || ''; + el.className = 'stat-trend ' + (cls[t[key]] || ''); + } + } catch {} +} + +// ── Offline banner ──────────────────────────────────────────────────────────── +function updateOfflineBanner(lastDateutc) { + const banner = document.getElementById('offlineBanner'); + if (!banner) return; + if (!lastDateutc) { banner.style.display = 'none'; return; } + const mins = Math.round((Date.now() - lastDateutc) / 60000); + if (mins > 15) { + banner.textContent = `⚠ Station last reported ${mins} minutes ago — may be offline`; + banner.style.display = 'block'; + } else { + banner.style.display = 'none'; + } +} + +// ── Monthly summary ─────────────────────────────────────────────────────────── +const monthlyPanel = document.getElementById('monthlyPanel'); +if (monthlyPanel) { + monthlyPanel.addEventListener('toggle', async () => { + if (!monthlyPanel.open) return; + const body = document.getElementById('monthlyBody'); + if (body.querySelector('table')) return; + try { + const r = await fetch('/api/station/monthly-summary'); + if (!r.ok) throw new Error(); + const data = await r.json(); + if (!data.length) { body.innerHTML = '
No monthly data yet.
'; return; } + const rows = data.slice().reverse().map(m => { + const [yr, mo] = m.month.split('-'); + const label = new Date(+yr, +mo - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + return ` + ${label} + ${m.avgTemp != null ? m.avgTemp.toFixed(1) + '°F' : '—'} + ${m.highTemp != null ? m.highTemp.toFixed(1) + '°F' : '—'} + ${m.highGust != null ? m.highGust + ' mph' : '—'} + ${m.totalRain != null ? m.totalRain.toFixed(2) + '"' : '—'} + ${m.readings.toLocaleString()} + `; + }).join(''); + body.innerHTML = `
+ + ${rows}
MonthAvg TempHigh TempMax GustTotal RainReadings
`; + } catch { document.getElementById('monthlyBody').innerHTML = '
Failed to load.
'; } + }, { once: false }); +} + +// ── Temperature heatmap ─────────────────────────────────────────────────────── +const heatmapPanel = document.getElementById('heatmapPanel'); +if (heatmapPanel) { + heatmapPanel.addEventListener('toggle', async () => { + if (!heatmapPanel.open) return; + const body = document.getElementById('heatmapBody'); + if (body.querySelector('.heatmap-wrap')) return; + try { + const r = await fetch('/api/station/history?range=1y'); + if (!r.ok) throw new Error(); + const readings = await r.json(); + if (!readings.length) { body.innerHTML = '
No data yet.
'; return; } + // Daily high temp + const byDay = {}; + for (const rd of readings) { + if (rd.tempf == null || !rd.dateutc) continue; + const day = new Date(rd.dateutc).toISOString().slice(0, 10); + if (byDay[day] == null || rd.tempf > byDay[day]) byDay[day] = rd.tempf; + } + // Color scale: cold = cool blue, hot = accent orange + function heatColor(t) { + if (t == null) return 'var(--border)'; + const norm = Math.max(0, Math.min(1, (t - 20) / 90)); + const r = Math.round(42 + norm * (224 - 42)); + const g = Math.round(107 + norm * (120 - 107)); + const b = Math.round(138 + norm * (42 - 138)); + return `rgb(${r},${g},${b})`; + } + // Build grid: iterate days from 1yr ago to today + const today = new Date(); today.setHours(23, 59, 59, 0); + const start = new Date(today); start.setFullYear(start.getFullYear() - 1); start.setDate(start.getDate() + 1); + // Align to Sunday + const startDow = start.getDay(); // 0=Sun + const cells = []; + for (let i = 0; i < startDow; i++) cells.push(null); // padding + const cur = new Date(start); + while (cur <= today) { + const key = cur.toISOString().slice(0, 10); + cells.push({ date: key, temp: byDay[key] ?? null }); + cur.setDate(cur.getDate() + 1); + } + const cellHtml = cells.map(c => { + if (!c) return `
`; + const t = c.temp; + const label = t != null ? `${c.date}: ${t.toFixed(1)}°F` : c.date; + return `
`; + }).join(''); + body.innerHTML = ` +
${cellHtml}
+
+ Cooler + ${[20,35,50,65,80,95,110].map(t => `
`).join('')} + Hotter +
`; + } catch { document.getElementById('heatmapBody').innerHTML = '
Failed to load.
'; } + }, { once: false }); +} + +// ── Station name ────────────────────────────────────────────────────────────── +async function loadStationName() { + try { + const r = await fetch('/api/dashboard/station-name'); + if (!r.ok) return; + const d = await r.json(); + const el = document.getElementById('inputStationName'); + if (el && d.name) el.value = d.name; + } catch {} +} +document.getElementById('saveNameBtn')?.addEventListener('click', async () => { + const name = document.getElementById('inputStationName')?.value?.trim(); + const msg = document.getElementById('nameMsg'); + if (!name) { msg.textContent = 'Enter a name'; msg.className = 'save-msg err'; return; } + try { + const r = await fetch('/api/dashboard/station-name', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); + if (r.ok) { msg.textContent = 'Saved'; msg.className = 'save-msg'; } + else { msg.textContent = 'Error'; msg.className = 'save-msg err'; } + setTimeout(() => msg.textContent = '', 3000); + } catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; } +}); + +// ── Change password ─────────────────────────────────────────────────────────── +document.getElementById('changePwBtn')?.addEventListener('click', async () => { + const cur = document.getElementById('pwCurrent')?.value; + const nw = document.getElementById('pwNew')?.value; + const msg = document.getElementById('pwMsg'); + if (!cur || !nw || nw.length < 6) { msg.textContent = 'Fill both fields (min 6 chars)'; msg.className = 'save-msg err'; return; } + try { + const r = await fetch('/api/dashboard/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: cur, newPassword: nw }) }); + if (r.ok) { msg.textContent = 'Password changed'; msg.className = 'save-msg'; document.getElementById('pwCurrent').value = ''; document.getElementById('pwNew').value = ''; } + else { const e = await r.json(); msg.textContent = e.error || 'Error'; msg.className = 'save-msg err'; } + setTimeout(() => msg.textContent = '', 4000); + } catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; } +}); + +// ── Alert thresholds ────────────────────────────────────────────────────────── +async function loadThresholds() { + try { + const r = await fetch('/api/subscriptions'); + if (!r.ok) return; + const subs = await r.json(); + const sub = subs?.find(s => s.thresholds); + if (!sub) return; + const t = sub.thresholds; + const set = (id, v) => { const el = document.getElementById(id); if (el && v) el.value = v; }; + set('thrTempHigh', t.tempHigh); + set('thrTempLow', t.tempLow); + set('thrWind', t.wind); + set('thrRainRate', t.rainRate); + } catch {} +} +document.getElementById('saveThreshBtn')?.addEventListener('click', async () => { + const msg = document.getElementById('threshMsg'); + const val = id => { const v = document.getElementById(id)?.value; return v ? parseFloat(v) : undefined; }; + const thresholds = {}; + const th = val('thrTempHigh'); if (th) thresholds.tempHigh = th; + const tl = val('thrTempLow'); if (tl) thresholds.tempLow = tl; + const wi = val('thrWind'); if (wi) thresholds.wind = wi; + const rr = val('thrRainRate'); if (rr) thresholds.rainRate = rr; + try { + const r = await fetch('/api/subscriptions/thresholds', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thresholds }) }); + if (r.ok) { msg.textContent = 'Saved — applies to all your subscriptions'; msg.className = 'save-msg'; } + else { msg.textContent = 'Error saving'; msg.className = 'save-msg err'; } + setTimeout(() => msg.textContent = '', 4000); + } catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; } +}); + // ── Init all new features ───────────────────────────────────────────────────── updateLatest(); updatePressureTrend(); updateExtStats(); +updateTrends(); loadRecords(); loadHistory(currentRange); checkKeyStatus(); +loadStationName(); +loadThresholds(); setInterval(updatePressureTrend, 120000); setInterval(updateExtStats, 300000); +setInterval(updateTrends, 120000); diff --git a/app/server.js b/app/server.js index 419d980..1e29e15 100644 --- a/app/server.js +++ b/app/server.js @@ -787,12 +787,51 @@ async function alertWatcherCron() { await doNtfy(sub.ntfyTopic, '😷 Air Quality Alert', body); } } - // ── Rain rate (station only) ───────────────────────────────────────── - if (sub.thresholds.rainRate && stationObs?.rainratein != null && stationObs.rainratein > sub.thresholds.rainRate && !alerted.rainRate) { - alerted.rainRate = true; - const body = `Rain rate at ${stationObs.rainratein.toFixed(2)} in/hr — your threshold is ${sub.thresholds.rainRate} in/hr`; - await doWebPush(sub, { title: '🌧 Heavy Rain Alert', body, tag: 'rain-threshold', url: '/' }); - await doNtfy(sub.ntfyTopic, '🌧 Heavy Rain Alert', body, 'high'); + // ── Station-based threshold alerts ─────────────────────────────────── + if (stationObs) { + if (sub.thresholds.rainRate && stationObs.rainratein != null && stationObs.rainratein > sub.thresholds.rainRate && !alerted.rainRate) { + alerted.rainRate = true; + const body = `Rain rate at ${stationObs.rainratein.toFixed(2)} in/hr — threshold is ${sub.thresholds.rainRate} in/hr`; + await doWebPush(sub, { title: '🌧 Heavy Rain Alert', body, tag: 'rain-threshold', url: '/' }); + await doNtfy(sub.ntfyTopic, '🌧 Heavy Rain Alert', body, 'high'); + } + if (sub.thresholds.tempHigh && stationObs.tempf != null && stationObs.tempf > sub.thresholds.tempHigh && !alerted.tempHigh) { + alerted.tempHigh = true; + const body = `Station temp at ${stationObs.tempf.toFixed(1)}°F — threshold is ${sub.thresholds.tempHigh}°F`; + await doWebPush(sub, { title: '🌡 High Temp Alert', body, tag: 'temp-high', url: '/' }); + await doNtfy(sub.ntfyTopic, '🌡 High Temp Alert', body, 'high'); + } + if (sub.thresholds.tempLow && stationObs.tempf != null && stationObs.tempf < sub.thresholds.tempLow && !alerted.stationTempLow) { + alerted.stationTempLow = true; + const body = `Station temp at ${stationObs.tempf.toFixed(1)}°F — threshold is ${sub.thresholds.tempLow}°F`; + await doWebPush(sub, { title: '🧊 Low Temp Alert', body, tag: 'temp-low', url: '/' }); + await doNtfy(sub.ntfyTopic, '🧊 Low Temp Alert', body, 'default'); + } + if (stationObs.tempf != null && stationObs.tempf <= 34 && !alerted.freeze) { + alerted.freeze = true; + const body = `Station reading ${stationObs.tempf.toFixed(1)}°F — freezing conditions`; + await doWebPush(sub, { title: '❄️ Freeze Warning', body, tag: 'freeze', url: '/' }); + await doNtfy(sub.ntfyTopic, '❄️ Freeze Warning', body, 'urgent'); + } + if (sub.thresholds.wind && stationObs.windgustmph != null && stationObs.windgustmph > sub.thresholds.wind && !alerted.stationWind) { + alerted.stationWind = true; + const body = `Station gust at ${stationObs.windgustmph.toFixed(0)} mph — threshold is ${sub.thresholds.wind} mph`; + await doWebPush(sub, { title: '💨 Wind Gust Alert', body, tag: 'wind-station', url: '/' }); + await doNtfy(sub.ntfyTopic, '💨 Wind Gust Alert', body, 'high'); + } + } + // ── Station offline alert ──────────────────────────────────────────── + if (!stationObs && ambientServerConfig.apiKey) { + const lastReading = lastRecordedDateutc; + if (lastReading && Date.now() - lastReading > 20 * 60 * 1000 && !alerted.stationOffline) { + alerted.stationOffline = true; + const mins = Math.round((Date.now() - lastReading) / 60000); + const body = `No station data for ${mins} minutes`; + await doWebPush(sub, { title: '📡 Station Offline', body, tag: 'station-offline', url: '/' }); + await doNtfy(sub.ntfyTopic, '📡 Station Offline', body, 'default'); + } + } else if (stationObs) { + alerted.stationOffline = false; } sub.lastThresholdAlerted = alerted; @@ -1167,7 +1206,110 @@ app.get('/conditions', (_req, res) => { app.get('/api/conditions/public', (_req, res) => { if (!ambientServerConfig.apiKey) return res.status(503).json({ error: 'Not configured' }); const cached = ambientCaches.get(ambientServerConfig.apiKey); - res.json(cached?.data?.[0]?.lastData || null); + const data = cached?.data?.[0]?.lastData || null; + if (!data) return res.json(null); + res.json({ ...data, stationName: dashAuth?.stationName || 'Genesis 8:22' }); +}); + +// ── Dashboard management APIs ───────────────────────────────────────────────── + +// Change password +app.post('/api/dashboard/change-password', requireDashboardAuth, (req, res) => { + const { currentPassword, newPassword } = req.body || {}; + if (!currentPassword || !newPassword || newPassword.length < 6) + return res.status(400).json({ error: 'Invalid input' }); + const hash = hashPassword(currentPassword, dashAuth.salt); + let match = false; + try { match = crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(dashAuth.hash, 'hex')); } catch {} + if (!match) return res.status(403).json({ error: 'Current password incorrect' }); + const salt = crypto.randomBytes(16).toString('hex'); + dashAuth = { ...dashAuth, salt, hash: hashPassword(newPassword, salt) }; + try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {} + res.json({ ok: true }); +}); + +// Station name +app.get('/api/dashboard/station-name', requireDashboardAuth, (_req, res) => { + res.json({ name: dashAuth?.stationName || 'Genesis 8:22' }); +}); +app.post('/api/dashboard/station-name', requireDashboardAuth, (req, res) => { + const { name } = req.body || {}; + if (!name || typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'Invalid name' }); + dashAuth = { ...dashAuth, stationName: name.trim().slice(0, 60) }; + try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {} + res.json({ ok: true, name: dashAuth.stationName }); +}); + +// Monthly summary — avg temp, total rain, high temp, high gust per month +app.get('/api/station/monthly-summary', requireDashboardAuth, (_req, res) => { + let files; + try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return res.json([]); } + const months = {}; + for (const file of files) { + try { + const lines = fs.readFileSync(path.join(STATION_DIR, file), 'utf8').split('\n').filter(Boolean); + for (const line of lines) { + try { + const r = JSON.parse(line); + if (!r.dateutc) continue; + const key = new Date(r.dateutc).toISOString().slice(0, 7); // YYYY-MM + if (!months[key]) months[key] = { temps: [], highTemp: null, highGust: null, dailyRainMax: {} }; + const m = months[key]; + if (r.tempf != null) { + m.temps.push(r.tempf); + if (m.highTemp == null || r.tempf > m.highTemp) m.highTemp = r.tempf; + } + if (r.windgustmph != null && (m.highGust == null || r.windgustmph > m.highGust)) m.highGust = r.windgustmph; + if (r.dailyrainin != null) { + const day = stationDateKey(r.dateutc); + if (m.dailyRainMax[day] == null || r.dailyrainin > m.dailyRainMax[day]) m.dailyRainMax[day] = r.dailyrainin; + } + } catch {} + } + } catch {} + } + const result = Object.entries(months).sort(([a], [b]) => a.localeCompare(b)).map(([key, m]) => ({ + month: key, + avgTemp: m.temps.length ? +(m.temps.reduce((a, b) => a + b, 0) / m.temps.length).toFixed(1) : null, + highTemp: m.highTemp != null ? +m.highTemp.toFixed(1) : null, + highGust: m.highGust != null ? +m.highGust.toFixed(0) : null, + totalRain: +Object.values(m.dailyRainMax).reduce((a, b) => a + b, 0).toFixed(2), + readings: m.temps.length, + })); + res.json(result); +}); + +// Subscriptions list (for reading thresholds in dashboard) +app.get('/api/subscriptions', requireDashboardAuth, (_req, res) => { + res.json(pushSubscriptions.map(s => ({ ntfyTopic: s.ntfyTopic, thresholds: s.thresholds || {}, hasWebPush: !!s.subscription }))); +}); + +// Update thresholds on all subscriptions at once +app.post('/api/subscriptions/thresholds', requireDashboardAuth, (req, res) => { + const { thresholds } = req.body || {}; + if (!thresholds || typeof thresholds !== 'object') return res.status(400).json({ error: 'Invalid' }); + for (const sub of pushSubscriptions) sub.thresholds = { ...(sub.thresholds || {}), ...thresholds }; + saveSubs(); + res.json({ ok: true, updated: pushSubscriptions.length }); +}); + +// Trend data — last reading vs 1h ago for each tile +app.get('/api/station/trend', requireDashboardAuth, (_req, res) => { + const hour = loadStationReadings(3600000); + if (hour.length < 2) return res.json({}); + const now = hour[hour.length - 1]; + const hourAgo = hour[0]; + const diff = (field) => { + const a = hourAgo[field], b = now[field]; + if (a == null || b == null) return null; + const d = b - a; + return d > 0.1 ? 'up' : d < -0.1 ? 'down' : 'flat'; + }; + res.json({ + tempf: diff('tempf'), humidity: diff('humidity'), dewPoint: diff('dewPoint'), + windspeedmph: diff('windspeedmph'), baromrelin: diff('baromrelin'), + uv: diff('uv'), solarradiation: diff('solarradiation'), + }); }); app.listen(PORT, () => {