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
View File
@@ -162,6 +162,18 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
</div>
</div>
<!-- NWS reference station -->
<div class="section">
<div class="section-hdr">NWS Reference Station</div>
<p class="hint" style="margin:0 0 0.85rem">Logs every official observation from the National Weather Service station nearest this ZIP code (plus hourly forecast snapshots and alerts) and overlays it on your charts.</p>
<div class="threshold-row">
<div><label>ZIP code</label><input id="nwsZip" type="text" inputmode="numeric" maxlength="5" placeholder="24550" autocomplete="off"></div>
<div style="align-self:end"><button class="primary" id="saveNwsBtn">Save &amp; look up</button></div>
</div>
<div class="settings-actions"><span id="nwsMsg" class="save-msg"></span></div>
<div id="nwsInfo" class="hint"></div>
</div>
<!-- Export / Import -->
<div class="section">
<div class="section-hdr">Data</div>
@@ -423,6 +435,35 @@ checkKeyStatus();
loadThresholds();
loadStationName();
loadLocation();
// ── NWS reference station ──────────────────────────────────────────────
async function loadNwsInfo() {
try {
const r = await fetch('/api/nws/config');
if (!r.ok) return;
const c = await r.json();
const zipEl = document.getElementById('nwsZip');
if (zipEl && !zipEl.value) zipEl.value = c.zip || '';
const res = c.resolved;
const when = (t) => t ? new Date(t).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
document.getElementById('nwsInfo').innerHTML = res
? `Station <b>${res.stationId}</b> — ${res.stationName} · ${res.distanceMi ?? '?'} mi from ZIP ${res.zip}${res.place ? ' (' + res.place + ')' : ''}${res.approx ? ' · <i>approximate ZIP location</i>' : ''}<br>${c.observations.toLocaleString()} observations logged · first ${when(c.firstObservation)} · latest ${when(c.lastObservation)}${c.lastError ? '<br><span style="color:var(--accent)">Last error: ' + c.lastError + '</span>' : ''}`
: (c.lastError ? `<span style="color:var(--accent)">${c.lastError}</span>` : 'Looking up the station for this ZIP…');
} catch {}
}
document.getElementById('saveNwsBtn')?.addEventListener('click', async () => {
const msg = document.getElementById('nwsMsg'), zip = document.getElementById('nwsZip').value.trim();
msg.textContent = 'Looking up…'; msg.className = 'save-msg';
try {
const r = await fetch('/api/nws/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ zip }) });
const d = await r.json();
if (!r.ok) throw new Error(d.error || 'Failed');
msg.textContent = 'Saved — logging has started'; await loadNwsInfo();
setTimeout(loadNwsInfo, 4000);
} catch (e) { msg.textContent = e.message; msg.className = 'save-msg err'; }
setTimeout(() => { msg.textContent = ''; }, 6000);
});
loadNwsInfo();
</script>
</body>
</html>
+6
View File
@@ -145,3 +145,9 @@ a { color: var(--ui); }
.ah-desc { white-space: pre-wrap; }
.ah-meaning { padding: 0.5rem 0.7rem; border-radius: 10px; background: color-mix(in srgb, var(--ui) 10%, transparent); }
.ah-note { font-size: 0.74rem; color: var(--ink-light); }
/* NWS overlay + card */
.nws-toggle.active { background: #7c5cff !important; border-color: #7c5cff !important; }
.nws-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #a78bfa; margin-left: 0.35rem; vertical-align: middle; }
.nws-card .kv b { font-size: 1.05rem; }
.nws-raw { display: block; margin-top: 0.4rem; font-size: 0.7rem; overflow-wrap: anywhere; color: var(--ink-light); background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: 0.35rem 0.55rem; }
+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]) {
+55 -15
View File
@@ -536,7 +536,7 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
</div>
</div>
<div id="sunHealthRow" class="two-col"><div id="sunCard"></div><div id="healthCard"></div></div>
<div id="sunHealthRow" class="two-col"><div id="sunCard"></div><div id="healthCard"></div><div id="nwsCard" style="grid-column:1/-1"></div></div>
</div><!-- /now -->
<div class="tab-pane" data-pane="charts">
@@ -555,6 +555,7 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
<button class="range-btn" data-range="all" title="Everything recorded so far">All</button>
<button class="refresh-btn" id="refreshBtn">↻ Refresh</button>
<button class="range-btn" id="yoyBtn" style="display:none" title="Overlay same period from 1 year ago">YoY</button>
<button class="range-btn nws-toggle" id="nwsBtn" title="Overlay official NWS observations for your reference ZIP">NWS overlay</button>
</div>
<div class="updated" id="updatedLine"></div>
@@ -846,8 +847,35 @@ function initPresTend(pagePts, pageRange) {
drawPresTend(pagePts, pageRange);
}
// ── NWS overlay (official observations for the reference ZIP) ─────────────────
let nwsOverlay = true;
try { nwsOverlay = localStorage.getItem('nwsOverlay') !== '0'; } catch {}
const NWS_COLOR = '#a78bfa';
// Map sparse NWS observations onto the chart's points (averaging when several land on one point)
function nwsAlign(pts, nws, key) {
if (!nws || !nws.length || !pts.length) return null;
const sum = new Array(pts.length).fill(0), cnt = new Array(pts.length).fill(0);
const span = pts.length > 1 ? (pts[pts.length - 1].dateutc - pts[0].dateutc) / (pts.length - 1) : 3600000;
const tol = Math.max(span / 2, 35 * 60000);
for (const o of nws) {
const v = o[key]; if (v == null) continue;
let lo = 0, hi = pts.length - 1;
while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m].dateutc < o.ts) lo = m + 1; else hi = m; }
let idx = lo;
if (idx > 0 && Math.abs(pts[idx - 1].dateutc - o.ts) < Math.abs(pts[idx].dateutc - o.ts)) idx--;
if (Math.abs(pts[idx].dateutc - o.ts) > tol) continue;
sum[idx] += v; cnt[idx]++;
}
const out = sum.map((t, i) => (cnt[i] ? +(t / cnt[i]).toFixed(2) : null));
return out.some(v => v != null) ? out : null;
}
function nwsDataset(label, data, dashed) {
return { label, data, borderColor: NWS_COLOR, backgroundColor: NWS_COLOR, borderWidth: 1.6, borderDash: dashed ? [4, 3] : [], pointRadius: 3, pointHoverRadius: 5, tension: 0, spanGaps: true, fill: false };
}
// ── Render charts ────────────────────────────────────────────────────────────
function renderCharts(readings, range, yoy = null) {
function renderCharts(readings, range, yoy = null, nws = null) {
const area = document.getElementById('chartsArea');
if (!readings.length) {
area.innerHTML = '<div class="status">No recorded data for this range yet. Data is collected every minute while the station is active.</div>';
@@ -953,28 +981,28 @@ function renderCharts(readings, range, yoy = null) {
}
tempDatasets.push(lineDataset('Temp °F', field(pts, 'tempf'), c.accent));
tempDatasets.push(lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true));
const nwsLab = nws && nws.length ? 'NWS ' + (nws[nws.length - 1].station || '') : 'NWS';
const addNws = (sets, key, suffix, dashed) => { const a = nwsAlign(pts, nws, key); if (a) sets.push(nwsDataset(`${nwsLab} ${suffix}`, a, dashed)); };
addNws(tempDatasets, 'tempf', 'temp');
if (yoy?.prior?.length) {
const yoyPts = yoy.prior.length > 720 ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / 720) === 0) : yoy.prior;
tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
}
makeChart('cTemp', 'line', tempDatasets, lbls, '°F', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
makeChart('cHum', 'line',
[lineDataset('Humidity %', field(pts, 'humidity'), c.cool),
lineDataset('Dew Point °F', field(pts, 'dewPoint'), c.accent, true)],
lbls, null);
const humSets = [lineDataset('Humidity %', field(pts, 'humidity'), c.cool), lineDataset('Dew Point °F', field(pts, 'dewPoint'), c.accent, true)];
addNws(humSets, 'humidity', 'humidity'); addNws(humSets, 'dewPoint', 'dew pt', true);
makeChart('cHum', 'line', humSets, lbls, null);
makeChart('cWind', 'line',
[lineDataset('Wind mph', field(pts, 'windspeedmph'), c.cool),
lineDataset('Gust mph', field(pts, 'windgustmph'), c.accent, true)],
lbls, 'mph');
const windSets = [lineDataset('Wind mph', field(pts, 'windspeedmph'), c.cool), lineDataset('Gust mph', field(pts, 'windgustmph'), c.accent, true)];
addNws(windSets, 'windspeedmph', 'wind'); addNws(windSets, 'windgustmph', 'gust', true);
makeChart('cWind', 'line', windSets, lbls, 'mph');
const hasPres = pts.some(r => r.baromrelin != null);
if (hasPres) {
makeChart('cPres', 'line',
[lineDataset('Rel. Pressure', field(pts, 'baromrelin'), c.accent),
lineDataset('Abs. Pressure', field(pts, 'baromabsin'), c.cool, true)],
lbls, 'inHg');
const presSets = [lineDataset('Rel. Pressure', field(pts, 'baromrelin'), c.accent), lineDataset('Abs. Pressure', field(pts, 'baromabsin'), c.cool, true)];
addNws(presSets, 'baromrelin', 'pressure');
makeChart('cPres', 'line', presSets, lbls, 'inHg');
}
makeChart('cRainRate', 'bar',
@@ -1135,11 +1163,12 @@ async function loadHistory(range) {
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
try {
const pts = ['7d','30d','90d','1y','2y','3y','all'].includes(range) ? '&points=720' : '';
const nwsP = nwsOverlay ? fetch(`/api/nws/history?range=${range}`).then(x => (x.ok ? x.json() : [])).catch(() => []) : Promise.resolve([]);
const r = await fetch(`/api/station/history?range=${range}${pts}`);
if (r.status === 401) { location.href = '/dashboard/login'; return; }
if (!r.ok) throw new Error('Failed');
const data = await r.json();
renderCharts(data, range, yoyEnabled ? yoyData : null);
renderCharts(data, range, yoyEnabled ? yoyData : null, await nwsP);
const line = document.getElementById('updatedLine');
if (line) {
const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@@ -1333,6 +1362,17 @@ async function loadRecords() {
let yoyEnabled = false;
let yoyData = null;
const nwsBtn = document.getElementById('nwsBtn');
if (nwsBtn) {
nwsBtn.classList.toggle('active', nwsOverlay);
nwsBtn.addEventListener('click', () => {
nwsOverlay = !nwsOverlay;
try { localStorage.setItem('nwsOverlay', nwsOverlay ? '1' : '0'); } catch {}
nwsBtn.classList.toggle('active', nwsOverlay);
loadHistory(currentRange);
});
}
const yoyBtn = document.getElementById('yoyBtn');
yoyBtn.addEventListener('click', async () => {
yoyEnabled = !yoyEnabled;