Add NWS alerts, dry/wet streak, and indoor sensor support to dashboard

- NWS active alerts shown as color-coded cards at top of dashboard (5-min cache), auto-refresh every 5 min
- Station location (lat/lon) configurable in settings; falls back to push subscription coordinates
- Consecutive dry/wet streak tile in extended stats (60-day lookback, 0.01" rain threshold)
- Indoor sensor tiles (Temp In, Humidity In, Dew Point In) auto-show when station reports tempinf/humidityin
- Indoor temperature and humidity history charts when indoor data is present in selected range

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 4.6 committed 2026-08-20 08:42:33 -04:00
1 parent e851fd1ca0
commit a535721bf8
2 files changed
+206

No files matched your search

+146
View File
@@ -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; }
<div class="page">
<div class="offline-banner" id="offlineBanner"></div>
<div id="alertsSection" style="margin-bottom:0"></div>
<!-- Current stats -->
<div class="stats" id="statsRow">
@@ -172,6 +182,9 @@ a { color: var(--cool); text-decoration: none; }
<div class="stat"><div class="stat-label">UV Index</div><div class="stat-val" id="sUV">—<span class="stat-trend" id="trUV"></span></div><div class="stat-unit"></div></div>
<div class="stat"><div class="stat-label">Solar Rad.</div><div class="stat-val" id="sSolar">—<span class="stat-trend" id="trSolar"></span></div><div class="stat-unit">W/m²</div></div>
<div class="stat"><div class="stat-label">Battery</div><div class="stat-val" id="sBatt">—</div><div class="stat-unit"></div></div>
<div class="stat" id="indoorTempTile" style="display:none"><div class="stat-label">Temp In</div><div class="stat-val" id="sTempIn">—</div><div class="stat-unit">°F</div></div>
<div class="stat" id="indoorHumTile" style="display:none"><div class="stat-label">Humidity In</div><div class="stat-val" id="sHumIn">—</div><div class="stat-unit">%</div></div>
<div class="stat" id="indoorDewTile" style="display:none"><div class="stat-label">Dew Point In</div><div class="stat-val" id="sDewIn">—</div><div class="stat-unit">°F</div></div>
</div>
<!-- Rain totals + degree days -->
@@ -181,6 +194,11 @@ a { color: var(--cool); text-decoration: none; }
<div class="stat"><div class="stat-label">Rain YTD</div><div class="stat-val" id="sRainYTD">—</div><div class="stat-unit">in</div></div>
<div class="stat"><div class="stat-label">Heating DD</div><div class="stat-val" id="sHDD">—</div><div class="stat-unit">this month</div></div>
<div class="stat"><div class="stat-label">Cooling DD</div><div class="stat-val" id="sCDD">—</div><div class="stat-unit">this month</div></div>
<div class="stat" id="sStreakTile" style="display:none">
<div class="stat-label">Current Streak</div>
<div class="stat-val" id="sStreak">—</div>
<div class="stat-unit" id="sStreakNote"></div>
</div>
<div class="stat" id="sAnomalyTile" style="display:none">
<div class="stat-label">Temp vs Normal</div>
<div class="stat-val" id="sAnomaly">—</div>
@@ -329,6 +347,19 @@ a { color: var(--cool); text-decoration: none; }
<hr class="settings-divider">
<div class="settings-section-hdr">Station Location (for NWS Alerts)</div>
<p class="keys-hint" style="margin:0 0 0.75rem">Used to fetch active NWS weather alerts for your area. If you have push notifications enabled, your saved location is used automatically.</p>
<div class="threshold-row">
<div><label>Latitude</label><input id="inputLat" type="number" step="0.0001" placeholder="e.g. 38.8977" autocomplete="off"></div>
<div><label>Longitude</label><input id="inputLon" type="number" step="0.0001" placeholder="e.g. -77.0365" autocomplete="off"></div>
</div>
<div class="settings-actions">
<button id="saveLocBtn">Save Location</button>
<span id="locMsg" class="save-msg"></span>
</div>
<hr class="settings-divider">
<div class="settings-section-hdr">Change Password</div>
<div class="settings-grid">
<div><label>Current Password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
@@ -588,6 +619,16 @@ function renderCharts(readings, range, yoy = null) {
<div class="chart-title">Solar Radiation (W/m²)</div>
<div class="chart-wrap"><canvas id="cSolar"></canvas></div>
</div>
${pts.some(r => r.tempinf != null) ? `
<div class="chart-card">
<div class="chart-title">Indoor Temperature & Dew Point (°F)</div>
<div class="chart-wrap"><canvas id="cTempIn"></canvas></div>
</div>
<div class="chart-card">
<div class="chart-title">Indoor Humidity (%)</div>
<div class="chart-wrap"><canvas id="cHumIn"></canvas></div>
</div>` : ''}
</div>
`;
@@ -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 `<div class="alert-card ${cls}">
<div class="alert-title">${p.event || 'Alert'}</div>
<div class="alert-meta">${p.areaDesc || ''}${ends ? ' · until ' + ends : ''}</div>
</div>`;
}).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);
</script>
</body>
</html>