diff --git a/app/public/dashboard.html b/app/public/dashboard.html index c988e0a..014f16b 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -110,6 +110,15 @@ 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; } +/* NWS alert cards */ +.alert-card { border-radius: 10px; padding: 0.6rem 1rem; margin-bottom: 0.5rem; font-size: 0.85rem; border: 1px solid; } +.alert-card.sev-extreme { background: rgba(120,0,0,0.12); border-color: #a00; color: var(--ink); } +.alert-card.sev-severe { background: rgba(194,90,42,0.12); border-color: var(--accent); } +.alert-card.sev-moderate{ background: rgba(200,160,0,0.12); border-color: #c8a000; } +.alert-card.sev-minor { background: rgba(42,107,138,0.12); border-color: var(--cool); } +.alert-card.sev-unknown { background: var(--paper-dark); border-color: var(--border); } +.alert-title { font-weight: 700; margin-bottom: 0.15rem; } +.alert-meta { font-size: 0.75rem; color: var(--ink-light); } /* Trend arrows */ .stat-trend { font-size: 0.85rem; margin-left: 0.25rem; } .trend-up { color: #e07848; } @@ -153,6 +162,7 @@ a { color: var(--cool); text-decoration: none; }
+
@@ -172,6 +182,9 @@ a { color: var(--cool); text-decoration: none; }
UV Index
—
Solar Rad.
—
W/m²
Battery
—
+ + +
@@ -181,6 +194,11 @@ a { color: var(--cool); text-decoration: none; }
Rain YTD
—
in
Heating DD
—
this month
Cooling DD
—
this month
+ `; @@ -635,6 +676,23 @@ function renderCharts(readings, range, yoy = null) { [lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)], lbls, 'W/m²', { scales: { y: { min: 0 } } }); + if (pts.some(r => r.tempinf != null)) { + const indoorDp = pts.map(r => { + if (r.tempinf == null || r.humidityin == null) return null; + const Tc = (r.tempinf - 32) * 5 / 9, RH = r.humidityin; + const dp = (243.04 * (Math.log(RH / 100) + 17.625 * Tc / (243.04 + Tc))) / + (17.625 - Math.log(RH / 100) - 17.625 * Tc / (243.04 + Tc)); + return +(dp * 9 / 5 + 32).toFixed(1); + }); + makeChart('cTempIn', 'line', + [lineDataset('Temp In °F', field(pts, 'tempinf'), c.accent), + lineDataset('Dew Point In', indoorDp, c.cool, true)], + lbls, '°F'); + makeChart('cHumIn', 'line', + [lineDataset('Humidity In %', field(pts, 'humidityin'), c.cool)], + lbls, '%', { scales: { y: { min: 0, max: 100 } } }); + } + // Wind rose const roseCanvas = document.getElementById('windRoseCanvas'); if (roseCanvas) { @@ -674,6 +732,22 @@ async function updateLatest() { set('sSolar', d.solarradiation, 0); set('sBatt', d.battout != null ? (d.battout === 1 ? 'OK' : 'Low') : '—', 0); + if (d.tempinf != null) { + set('sTempIn', d.tempinf); + document.getElementById('indoorTempTile').style.display = ''; + } + if (d.humidityin != null) { + set('sHumIn', d.humidityin, 0); + document.getElementById('indoorHumTile').style.display = ''; + } + if (d.tempinf != null && d.humidityin != null) { + const Tc = (d.tempinf - 32) * 5 / 9, RH = d.humidityin; + const dp = (243.04 * (Math.log(RH / 100) + 17.625 * Tc / (243.04 + Tc))) / + (17.625 - Math.log(RH / 100) - 17.625 * Tc / (243.04 + Tc)); + set('sDewIn', +(dp * 9 / 5 + 32).toFixed(1), 1); + document.getElementById('indoorDewTile').style.display = ''; + } + const name = document.getElementById('stationName'); if (name && d.PASSKEY) name.textContent = ''; @@ -1143,6 +1217,74 @@ document.getElementById('saveThreshBtn')?.addEventListener('click', async () => } catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; } }); +// ── NWS Alerts ──────────────────────────────────────────────────────────────── +const SEV_CLASS = { Extreme: 'sev-extreme', Severe: 'sev-severe', Moderate: 'sev-moderate', Minor: 'sev-minor' }; +async function loadAlerts() { + const section = document.getElementById('alertsSection'); + if (!section) return; + try { + const r = await fetch('/api/dashboard/alerts'); + if (!r.ok) return; + const data = await r.json(); + const features = data.features || []; + if (!features.length) { section.innerHTML = ''; section.style.marginBottom = '0'; return; } + section.style.marginBottom = '1rem'; + section.innerHTML = features.map(f => { + const p = f.properties || {}; + const sev = p.severity || 'unknown'; + const cls = SEV_CLASS[sev] || 'sev-unknown'; + const ends = p.ends ? new Date(p.ends).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''; + return `
+
${p.event || 'Alert'}
+
${p.areaDesc || ''}${ends ? ' · until ' + ends : ''}
+
`; + }).join(''); + } catch {} +} + +// ── Streak ───────────────────────────────────────────────────────────────────── +async function loadStreak() { + try { + const r = await fetch('/api/station/streak'); + if (!r.ok) return; + const d = await r.json(); + if (!d.type || !d.count) return; + const tile = document.getElementById('sStreakTile'); + tile.style.display = ''; + document.getElementById('sStreak').textContent = d.count; + document.getElementById('sStreakNote').textContent = d.count === 1 ? `${d.type} day` : `consecutive ${d.type} days`; + tile.style.borderColor = d.type === 'wet' ? 'var(--cool)' : 'var(--border)'; + } catch {} +} + +// ── Station location ────────────────────────────────────────────────────────── +async function loadLocation() { + try { + const r = await fetch('/api/dashboard/location'); + if (!r.ok) return; + const loc = await r.json(); + if (!loc) return; + const latEl = document.getElementById('inputLat'); + const lonEl = document.getElementById('inputLon'); + if (latEl) latEl.value = loc.lat; + if (lonEl) lonEl.value = loc.lon; + } catch {} +} +document.getElementById('saveLocBtn')?.addEventListener('click', async () => { + const msg = document.getElementById('locMsg'); + const lat = parseFloat(document.getElementById('inputLat')?.value); + const lon = parseFloat(document.getElementById('inputLon')?.value); + if (!isFinite(lat) || !isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) { + msg.textContent = 'Enter valid lat/lon'; msg.className = 'save-msg err'; return; + } + try { + const r = await fetch('/api/dashboard/location', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat, lon }) }); + if (r.ok) { msg.textContent = 'Saved'; msg.className = 'save-msg'; loadAlerts(); } + else { msg.textContent = 'Error'; msg.className = 'save-msg err'; } + setTimeout(() => msg.textContent = '', 3000); + } catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; } +}); + // ── Init all new features ───────────────────────────────────────────────────── updateLatest(); updatePressureTrend(); @@ -1153,10 +1295,14 @@ loadHistory(currentRange); checkKeyStatus(); loadStationName(); loadThresholds(); +loadAlerts(); +loadStreak(); +loadLocation(); setInterval(updatePressureTrend, 120000); setInterval(updateExtStats, 300000); setInterval(updateTrends, 120000); +setInterval(loadAlerts, 5 * 60 * 1000); diff --git a/app/server.js b/app/server.js index 1e29e15..f2998b5 100644 --- a/app/server.js +++ b/app/server.js @@ -1293,6 +1293,66 @@ app.post('/api/subscriptions/thresholds', requireDashboardAuth, (req, res) => { res.json({ ok: true, updated: pushSubscriptions.length }); }); +// Station location (for dashboard alerts) +app.get('/api/dashboard/location', requireDashboardAuth, (_req, res) => { + if (dashAuth?.stationLat != null) + return res.json({ lat: dashAuth.stationLat, lon: dashAuth.stationLon }); + if (pushSubscriptions.length) + return res.json({ lat: pushSubscriptions[0].lat, lon: pushSubscriptions[0].lon, fromSubscription: true }); + res.json(null); +}); +app.post('/api/dashboard/location', requireDashboardAuth, (req, res) => { + const { lat, lon } = req.body || {}; + if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) + return res.status(400).json({ error: 'Invalid coordinates' }); + dashAuth = { ...dashAuth, stationLat: +lat, stationLon: +lon }; + try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {} + res.json({ ok: true }); +}); + +// NWS alerts for the station location (5-min cache) +const dashAlertCache = new Map(); +app.get('/api/dashboard/alerts', requireDashboardAuth, async (_req, res) => { + let lat = dashAuth?.stationLat; + let lon = dashAuth?.stationLon; + if (lat == null && pushSubscriptions.length) { lat = pushSubscriptions[0].lat; lon = pushSubscriptions[0].lon; } + if (lat == null) return res.json({ features: [], noLocation: true }); + const cacheKey = `${lat},${lon}`; + const cached = dashAlertCache.get(cacheKey); + if (cached && Date.now() - cached.at < 5 * 60 * 1000) return res.json(cached.data); + try { + const r = await fetchWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { + attempts: 1, timeoutMs: 12000, + fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } }, + }); + const data = await r.json(); + dashAlertCache.set(cacheKey, { data, at: Date.now() }); + res.json(data); + } catch { res.json({ features: [] }); } +}); + +// Consecutive dry/wet streak (from daily rain totals) +app.get('/api/station/streak', requireDashboardAuth, (_req, res) => { + const readings = loadStationReadings(60 * 86400000); + const byDay = {}; + for (const r of readings) { + if (r.dailyrainin == null || !r.dateutc) continue; + const d = stationDateKey(r.dateutc); + if (byDay[d] == null || r.dailyrainin > byDay[d]) byDay[d] = r.dailyrainin; + } + const days = Object.keys(byDay).sort().reverse(); + if (!days.length) return res.json({ type: null, count: 0 }); + const latestRain = byDay[days[0]]; + const streakType = latestRain >= 0.01 ? 'wet' : 'dry'; + let count = 0; + for (const d of days) { + const rain = byDay[d]; + if (streakType === 'wet' ? rain >= 0.01 : rain < 0.01) count++; + else break; + } + res.json({ type: streakType, count }); +}); + // Trend data — last reading vs 1h ago for each tile app.get('/api/station/trend', requireDashboardAuth, (_req, res) => { const hour = loadStationReadings(3600000);