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:
1 parent
e851fd1ca0
commit
a535721bf8
2 files changed
+206
No files matched your search
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user