Log official NWS observations for a reference ZIP and overlay them on the dashboard
Publish Container / publish (push) Successful in 2m33s

- Resolves ZIP (default 24550) to the nearest NWS observation station; logs every observation
  (normalised imperial values + raw METAR), back-fills ~7 days, snapshots the hourly forecast
  every 3h, and logs alerts for that point
- Chart overlay (temp, humidity/dew point, wind, pressure) with an NWS toggle in the History bar
- 'Official NWS observation' card on the Now tab comparing against your station
- Settings: change the reference ZIP and see logging status
- Exact-unit conversions for NWS values (verified against known conversions)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 13:41:02 -04:00
1 parent 5ed673bacc
commit acdcd6a2c5
5 files changed
+364 -20

No files matched your search

+41 -2
View File
@@ -249,6 +249,7 @@ function renderNowExtras() {
updateCompass(LATEST);
renderSunCard();
renderHealthCard();
renderNwsCard();
renderHighlights();
}
@@ -569,14 +570,52 @@ function renderInsights() {
} catch {}
})();
// ── Official NWS observation vs your station ─────────────────────────────────
let NWS = null;
function renderNwsCard() {
const host = $('nwsCard');
if (!host) return;
const n = NWS, l = n?.latest, r = n?.resolved;
if (!n) { host.innerHTML = ''; return; }
if (!l) {
host.innerHTML = `<div class="ui-card"><h3>Official NWS observation</h3><p class="note">${n.lastError ? 'Could not reach the National Weather Service yet: ' + n.lastError : 'Waiting for the first observation from the National Weather Service…'}${r ? ` Station <b>${r.stationId}</b> for ZIP ${r.zip}.` : ''}</p></div>`;
return;
}
const f = (v, d = 0, u = '') => (num(v) == null ? '—' : v.toFixed(d) + u);
const ageMin = Math.round((Date.now() - l.ts) / 60000);
const mine = LATEST;
const diffs = [];
if (mine && num(mine.tempf) != null && num(l.tempf) != null && Math.abs(mine.dateutc - l.ts) < 90 * 60000) {
const d = mine.tempf - l.tempf;
diffs.push(Math.abs(d) < 0.5 ? 'Your station matches NWS on temperature' : `Your station reads <b>${Math.abs(d).toFixed(1)}°F ${d > 0 ? 'warmer' : 'cooler'}</b>`);
if (num(mine.humidity) != null && num(l.humidity) != null && Math.abs(mine.humidity - l.humidity) >= 3) diffs.push(`${Math.abs(mine.humidity - l.humidity).toFixed(0)}% ${mine.humidity > l.humidity ? 'more humid' : 'drier'}`);
}
const wind = num(l.windspeedmph) != null ? `${l.winddir != null ? cardinal(l.winddir) + ' ' : ''}${l.windspeedmph.toFixed(0)} mph${num(l.windgustmph) != null ? ' (g ' + l.windgustmph.toFixed(0) + ')' : ''}` : '—';
host.innerHTML = `<div class="ui-card nws-card"><h3>Official NWS observation <span class="nws-dot"></span></h3>
<p class="note" style="margin:-0.4rem 0 0.8rem"><b>${esc2(r?.stationId || l.station)}</b> ${esc2(r?.stationName || '')} · ${r?.distanceMi != null ? r.distanceMi + ' mi from ' : 'for '}ZIP ${esc2(r?.zip || '')}${r?.place ? ' (' + esc2(r.place) + ')' : ''}</p>
<div class="kv">
<div><b>${f(l.tempf, 1, '°F')}</b><span>Temperature</span></div>
<div><b>${f(l.dewPoint, 0, '°F')}</b><span>Dew point</span></div>
<div><b>${f(l.humidity, 0, '%')}</b><span>Humidity</span></div>
<div><b>${wind}</b><span>Wind</span></div>
<div><b>${f(l.baromrelin, 2, ' inHg')}</b><span>Pressure</span></div>
<div><b>${f(l.visibilityMi, 0, ' mi')}</b><span>Visibility</span></div>
<div><b>${esc2(l.text || '—')}</b><span>Conditions</span></div>
</div>
<p class="note" style="margin-top:0.8rem">Observed ${new Date(l.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} (${ageMin} min ago)${diffs.length ? ' · ' + diffs.join(' · ') : ''} · ${n.observations.toLocaleString()} observations logged${n.firstObservation ? ' since ' + prettyDay(dayStr(n.firstObservation)) : ''}${l.raw ? `<br><code class="nws-raw">${esc2(l.raw)}</code>` : ''}</p></div>`;
}
const esc2 = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// ── Data refresh ─────────────────────────────────────────────────────────────
async function refreshData() {
try {
const [daily, recent] = await Promise.all([
const [daily, recent, nws] = await Promise.all([
getJSON('/api/station/daily').catch(() => []),
getJSON('/api/station/history?range=2d').catch(() => []),
getJSON('/api/nws/latest').catch(() => null),
]);
DAILY = daily; RECENT = recent;
DAILY = daily; RECENT = recent; NWS = nws;
LATEST = RECENT[RECENT.length - 1] || null;
const allBtn = document.querySelector('.range-btn[data-range="all"]');
if (allBtn && DAILY[0]) {