Files
weather/app/public/dashboard-ui.js
T
Nate EmmertandClaude Sonnet 5.5 acdcd6a2c5
Publish Container / publish (push) Successful in 2m33s
Log official NWS observations for a reference ZIP and overlay them on the dashboard
- 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>
2026-10-15 13:41:02 -04:00

647 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Dashboard UI layer — builds on the globals from dashboard.html (charts, makeChart, chartColors, …) */
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const fmt = (v, d = 1) => (num(v) == null ? '—' : v.toFixed(d));
const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null);
const sum = (a) => a.reduce((x, y) => x + y, 0);
const MS_DAY = 86400000;
let DAILY = []; // per-day summaries from /api/station/daily (station-local days)
let RECENT = []; // raw readings, last 48h
let LATEST = null;
let LOC = null;
let TZ = 'America/New_York';
let rainRange = 90;
let calMetric = 'hi';
const rendered = {}; // which lazy tabs have been drawn for the current data
async function getJSON(url) {
const r = await fetch(url);
if (r.status === 401) { location.href = '/dashboard/login'; throw new Error('auth'); }
if (!r.ok) throw new Error(url);
return r.json();
}
// ── Date helpers (station-local day strings, YYYY-MM-DD) ─────────────────────
function dayStr(ts, tz = TZ) {
try { return new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(new Date(ts)); }
catch { return new Date(ts).toISOString().slice(0, 10); }
}
const dayMs = (s) => Date.parse(s + 'T12:00:00Z');
const addDays = (s, n) => new Date(dayMs(s) + n * MS_DAY).toISOString().slice(0, 10);
const todayStr = () => dayStr(Date.now());
const prettyDay = (s) => new Date(dayMs(s)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
const prettyMonth = (m) => { const d = new Date(m + '-15T12:00:00Z'); return d.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' }) + (d.getUTCMonth() === 0 || true ? ` '${String(d.getUTCFullYear()).slice(2)}` : ''); };
const todayRow = () => DAILY.find((d) => d.d === todayStr()) || null;
// ── Tabs ─────────────────────────────────────────────────────────────────────
const TABS = ['now', 'charts', 'rain', 'records', 'insights'];
function setTab(name, updateHash = true) {
if (!TABS.includes(name)) name = 'now';
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-pane').forEach((p) => p.classList.toggle('active', p.dataset.pane === name));
if (updateHash) history.replaceState(null, '', '#' + name);
try { sessionStorage.setItem('dashTab', name); } catch {}
requestAnimationFrame(() => Object.values(typeof charts !== 'undefined' ? charts : {}).forEach((c) => { try { c.resize(); } catch {} }));
renderTab(name);
if (updateHash) window.scrollTo({ top: 0 });
}
function activeTab() { return document.querySelector('.tab.active')?.dataset.tab || 'now'; }
function renderTab(name) {
if (name === 'rain' && !rendered.rain) { renderRain(); rendered.rain = true; }
if (name === 'records' && !rendered.cal) { renderCalendar(); rendered.cal = true; }
if (name === 'insights' && !rendered.insights) { renderInsights(); rendered.insights = true; }
}
document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => setTab(t.dataset.tab)));
window.addEventListener('hashchange', () => setTab(location.hash.slice(1), false));
// Tabs replace the collapsible panels: keep every panel open (their lazy loaders fire on toggle)
document.querySelectorAll('details.settings-panel').forEach((d) => {
d.classList.add('always-open');
d.querySelector('summary')?.addEventListener('click', (e) => e.preventDefault());
d.open = true;
});
// Keep sticky tab bar flush under the (variable-height) header
const headerEl = document.querySelector('.header');
if (headerEl && 'ResizeObserver' in window) {
new ResizeObserver(() => document.documentElement.style.setProperty('--header-h', headerEl.offsetHeight + 'px')).observe(headerEl);
}
// ── Astronomy ────────────────────────────────────────────────────────────────
const RAD = Math.PI / 180;
function solarPos(ts, lat, lon) {
const d = ts / MS_DAY + 2440587.5 - 2451545.0;
const L = (280.46 + 0.9856474 * d) % 360;
const g = ((357.528 + 0.9856003 * d) % 360) * RAD;
const lam = (L + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * RAD;
const eps = (23.439 - 0.0000004 * d) * RAD;
const dec = Math.asin(Math.sin(eps) * Math.sin(lam));
const ra = Math.atan2(Math.cos(eps) * Math.sin(lam), Math.cos(lam));
const gmst = ((280.46061837 + 360.98564736629 * d) % 360) * RAD;
const H = gmst + lon * RAD - ra;
const sinEl = Math.sin(lat * RAD) * Math.sin(dec) + Math.cos(lat * RAD) * Math.cos(dec) * Math.cos(H);
return { sinEl, elevation: Math.asin(sinEl) / RAD };
}
// Haurwitz clear-sky global horizontal irradiance (W/m²)
window.clearSkyGHI = function (ts) {
if (!LOC) return null;
const s = solarPos(ts, LOC.lat, LOC.lon).sinEl;
return s > 0.02 ? Math.round(1098 * s * Math.exp(-0.057 / s)) : 0;
};
function sunTimes(noonMs, lat, lon) {
const jd = noonMs / MS_DAY + 2440587.5;
const n = Math.ceil(jd - 2451545.0 + 0.0008);
const Js = n - lon / 360;
const M = ((357.5291 + 0.98560028 * Js) % 360) * RAD;
const C = 1.9148 * Math.sin(M) + 0.02 * Math.sin(2 * M) + 0.0003 * Math.sin(3 * M);
const lam = (((M / RAD + C + 180 + 102.9372) % 360)) * RAD;
const Jt = 2451545.0 + Js + 0.0053 * Math.sin(M) - 0.0069 * Math.sin(2 * lam);
const sinDec = Math.sin(lam) * Math.sin(23.4397 * RAD);
const cosDec = Math.cos(Math.asin(sinDec));
const cosW = (Math.sin(-0.833 * RAD) - Math.sin(lat * RAD) * sinDec) / (Math.cos(lat * RAD) * cosDec);
if (cosW > 1 || cosW < -1) return null; // polar day/night
const w = Math.acos(cosW) / RAD;
const toMs = (j) => (j - 2440587.5) * MS_DAY;
// The julian-day rounding above can land on the neighbouring day; align to the requested day
const shift = Math.round((noonMs - toMs(Jt)) / MS_DAY) * MS_DAY;
return { rise: toMs(Jt - w / 360) + shift, set: toMs(Jt + w / 360) + shift, noon: toMs(Jt) + shift };
}
const SYNODIC = 29.530588853;
const NEW_MOON = Date.UTC(2000, 0, 6, 18, 14);
function moonInfo(ts) {
const phase = ((((ts - NEW_MOON) / MS_DAY) / SYNODIC) % 1 + 1) % 1;
const names = ['New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'];
const icons = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
const i = Math.round(phase * 8) % 8;
const untilFull = ((0.5 - phase + 1) % 1) * SYNODIC;
const untilNew = ((1 - phase) % 1) * SYNODIC;
return { name: names[i], icon: icons[i], illum: (1 - Math.cos(2 * Math.PI * phase)) / 2, untilFull, untilNew };
}
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const hm = (ms) => { const m = Math.round(ms / 60000); return `${Math.floor(m / 60)}h ${m % 60}m`; };
// ── Now tab ──────────────────────────────────────────────────────────────────
const DELTAS = [
{ id: 'sHum', key: 'humidity', unit: '%', d: 0, eps: 1 },
{ id: 'sDew', key: 'dewPoint', unit: '°', d: 1, eps: 0.5 },
{ id: 'sPres', key: 'baromrelin', unit: ' inHg', d: 2, eps: 0.01 },
{ id: 'sWind', key: 'windspeedmph', unit: ' mph', d: 1, eps: 0.5 },
{ id: 'sUV', key: 'uv', unit: '', d: 1, eps: 0.5 },
{ id: 'sSolar', key: 'solarradiation', unit: ' W/m²', d: 0, eps: 10 },
];
function nearestReading(ts, tolMs = 30 * 60000) {
let best = null, bd = Infinity;
for (const r of RECENT) { const dd = Math.abs(r.dateutc - ts); if (dd < bd) { bd = dd; best = r; } }
return bd <= tolMs ? best : null;
}
function setDelta(host, key, cur, prev, unit, d, eps, after) {
if (!host) return;
let el = host.querySelector(`.delta[data-k="${key}"]`);
if (!el) {
el = document.createElement('div');
el.className = 'delta'; el.dataset.k = key;
if (after && after.parentNode === host) after.after(el); else host.appendChild(el);
}
if (num(cur) == null || num(prev) == null) { el.textContent = ''; return; }
const diff = cur - prev;
const cls = Math.abs(diff) < eps ? 'flat' : diff > 0 ? 'up' : 'down';
el.className = 'delta ' + cls;
el.textContent = cls === 'flat' ? 'Same as yesterday' : `${diff > 0 ? '▲' : '▼'} ${Math.abs(diff).toFixed(d)}${unit} vs yesterday`;
}
function drawArea(canvas, values, color) {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const W = canvas.clientWidth || 200, H = canvas.clientHeight || 60;
canvas.width = W * dpr; canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const vals = values.filter((v) => v != null);
if (vals.length < 2) return;
const min = Math.min(...vals), max = Math.max(...vals), rng = max - min || 1;
const step = W / (values.length - 1);
const pts = [];
values.forEach((v, i) => { if (v != null) pts.push([i * step, H - 4 - ((v - min) / rng) * (H - 12)]); });
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, color + '66'); g.addColorStop(1, color + '00');
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke();
ctx.lineTo(pts[pts.length - 1][0], H); ctx.lineTo(pts[0][0], H); ctx.closePath();
ctx.fillStyle = g; ctx.fill();
}
function ensureHeroExtras() {
const main = document.querySelector('.hero-main');
if (!main || main.querySelector('.hero-spark')) return;
const cv = document.createElement('canvas');
cv.className = 'hero-spark'; cv.id = 'heroSpark';
main.prepend(cv);
const hilo = document.createElement('div');
hilo.className = 'hero-hilo'; hilo.id = 'heroHiLo';
$('heroWindLine')?.after(hilo);
}
function ensureWindCompass() {
if ($('windCompass')) return;
const tile = $('statWindTile');
const stats = tile?.closest('.stats');
if (!stats) return;
const wrap = document.createElement('div');
wrap.className = 'wind-wrap';
wrap.innerHTML = '<div id="windCompass"></div>';
stats.before(wrap);
wrap.appendChild(stats);
}
function updateCompass(r) {
const el = $('windCompass');
if (!el || !r) return;
const dir = num(r.winddir), spd = num(r.windspeedmph) ?? 0;
const gust = num(r.windgustmph);
const rot = dir != null ? (dir + 180) % 360 : 0; // arrow shows where the wind is heading
const ticks = Array.from({ length: 16 }, (_, i) => {
const a = i * 22.5 * RAD, big = i % 4 === 0;
const x1 = 58 + Math.sin(a) * (big ? 44 : 48), y1 = 58 - Math.cos(a) * (big ? 44 : 48);
const x2 = 58 + Math.sin(a) * 52, y2 = 58 - Math.cos(a) * 52;
return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="var(--border)" stroke-width="${big ? 1.6 : 1}"/>`;
}).join('');
el.innerHTML = `<svg viewBox="0 0 116 116" role="img" aria-label="Wind ${dir != null ? cardinal(dir) : 'calm'} ${spd.toFixed(1)} mph">
<circle cx="58" cy="58" r="52" fill="var(--paper)" stroke="var(--border)"/>${ticks}
<g font-size="9" font-weight="700" fill="var(--ink-light)" text-anchor="middle">
<text x="58" y="19">N</text><text x="100" y="61">E</text><text x="58" y="103">S</text><text x="16" y="61">W</text></g>
${spd > 0.5 && dir != null ? `<g transform="rotate(${rot} 58 58)"><path d="M58 24 L67 56 L58 50 L49 56 Z" fill="var(--accent)"/><line x1="58" y1="50" x2="58" y2="86" stroke="var(--accent)" stroke-width="2" stroke-linecap="round"/></g>` : ''}
<text x="58" y="60" text-anchor="middle" font-size="15" font-weight="800" fill="var(--ink)">${spd > 0.5 ? spd.toFixed(0) : '0'}</text>
<text x="58" y="72" text-anchor="middle" font-size="7.5" fill="var(--ink-light)">mph${dir != null && spd > 0.5 ? ' · ' + cardinal(dir) : ''}</text>
${gust != null && gust > spd + 1 ? `<text x="58" y="82" text-anchor="middle" font-size="7" fill="var(--ink-light)">gust ${gust.toFixed(0)}</text>` : ''}
</svg>`;
}
function renderNowExtras() {
ensureHeroExtras();
ensureWindCompass();
if (!LATEST) return;
const prev = nearestReading(LATEST.dateutc - MS_DAY);
// Delta lines under each tile
for (const m of DELTAS) {
const val = document.getElementById(m.id);
const host = val?.parentElement;
setDelta(host, m.key, LATEST[m.key], prev?.[m.key], m.unit, m.d, m.eps, host?.querySelector('.hero-stat-unit, .stat-unit') || val);
}
setDelta(document.querySelector('.hero-main'), 'tempf', LATEST.tempf, prev?.tempf, '°', 1, 0.5, document.querySelector('.hero-temp-row'));
// Hero 24h temperature trace
const last24 = RECENT.filter((r) => r.dateutc >= LATEST.dateutc - MS_DAY);
drawArea($('heroSpark'), last24.map((r) => r.tempf), chartColors().accent);
if (typeof updateSparklines === 'function') updateSparklines(last24);
// Today's range (from the persistent daily store — always the station-local day)
const t = todayRow();
if (t) {
if ($('heroHiLo')) $('heroHiLo').innerHTML = `<span>Today</span><span class="hi">↑ ${fmt(t.hi)}°</span><span class="lo">↓ ${fmt(t.lo)}°</span>`;
$('todayHigh').textContent = fmt(t.hi); $('todayLow').textContent = fmt(t.lo);
$('todayRain').textContent = fmt(t.rain, 2); $('todayGust').textContent = fmt(t.gust);
$('todayBar').style.display = '';
}
updateCompass(LATEST);
renderSunCard();
renderHealthCard();
renderNwsCard();
renderHighlights();
}
function renderSunCard() {
const host = $('sunCard');
if (!host) return;
if (!LOC) {
host.innerHTML = `<div class="ui-card"><h3>Sun &amp; Moon</h3><p class="note">Set your station location in <a href="/dashboard/settings">Settings</a> to see sunrise, sunset, day length and moon phase.</p></div>`;
return;
}
const now = Date.now();
const n = new Date();
const noon = new Date(n.getFullYear(), n.getMonth(), n.getDate(), 12).getTime();
const st = sunTimes(noon, LOC.lat, LOC.lon);
const stTomorrow = sunTimes(noon + MS_DAY, LOC.lat, LOC.lon);
const moon = moonInfo(now);
const el = solarPos(now, LOC.lat, LOC.lon).elevation;
let arc = '';
let body = '<p class="note">No sunrise/sunset at this latitude today.</p>';
if (st) {
const f = Math.max(0, Math.min(1, (now - st.rise) / (st.set - st.rise)));
const day = now >= st.rise && now <= st.set;
const x = 20 + 260 * f, y = 76 - 58 * Math.sin(Math.PI * f);
arc = `<svg class="sun-arc" viewBox="0 0 300 86" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<line x1="10" y1="76" x2="290" y2="76" stroke="var(--border)"/>
<path d="M20 76 A130 58 0 0 1 280 76" fill="none" stroke="var(--border)" stroke-dasharray="4 4"/>
<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="8" fill="${day ? '#f5b82e' : 'var(--ink-light)'}" opacity="${day ? 1 : 0.5}"/></svg>`;
const delta = stTomorrow ? (stTomorrow.set - stTomorrow.rise) - (st.set - st.rise) : null;
body = `<div class="kv">
<div><b>${clock(st.rise)}</b><span>Sunrise</span></div>
<div><b>${clock(st.set)}</b><span>Sunset</span></div>
<div><b>${hm(st.set - st.rise)}</b><span>Daylight</span>${delta != null ? `<small>${delta >= 0 ? '+' : '−'}${Math.abs(Math.round(delta / 1000))}s tomorrow</small>` : ''}</div>
<div><b>${clock(st.noon)}</b><span>Solar noon</span><small>sun ${el > 0 ? Math.round(el) + '° up' : 'below horizon'}</small></div>
</div>`;
}
host.innerHTML = `<div class="ui-card"><h3>Sun &amp; Moon</h3>${arc}${body}
<div class="kv" style="margin-top:0.9rem;padding-top:0.8rem;border-top:1px solid var(--border)">
<div><b>${moon.icon} ${Math.round(moon.illum * 100)}%</b><span>${moon.name}</span></div>
<div><b>${prettyDay(dayStr(now + moon.untilFull * MS_DAY))}</b><span>Next full</span></div>
<div><b>${prettyDay(dayStr(now + moon.untilNew * MS_DAY))}</b><span>Next new</span></div>
</div></div>`;
}
function medianIntervalMin() {
const d = [];
for (let i = 1; i < RECENT.length; i++) { const x = RECENT[i].dateutc - RECENT[i - 1].dateutc; if (x > 0 && x < 20 * 60000) d.push(x); }
if (!d.length) return null;
d.sort((a, b) => a - b);
return d[d.length >> 1] / 60000;
}
function coverageFor(row, perDay) { return row && perDay ? Math.min(100, (row.n / perDay) * 100) : null; }
function renderHealthCard() {
const host = $('healthCard');
if (!host || !LATEST) return;
const ageMin = Math.round((Date.now() - LATEST.dateutc) / 60000);
const ok = ageMin <= 10;
const interval = medianIntervalMin();
const perDay = interval ? 1440 / interval : null;
const past = DAILY.filter((d) => d.d !== todayStr()).slice(-30);
const cov = perDay && past.length ? mean(past.map((d) => coverageFor(d, perDay))) : null;
const t = todayRow();
host.innerHTML = `<div class="ui-card"><h3>Station Health</h3><div class="kv">
<div><b><span class="pill ${ok ? 'ok' : 'warn'}">${ok ? 'Online' : 'Delayed'}</span></b><span>Last report</span><small>${ageMin <= 0 ? 'just now' : ageMin + ' min ago'}</small></div>
<div><b>${LATEST.battout == null ? '—' : LATEST.battout === 1 ? 'OK' : 'Low'}</b><span>Battery</span></div>
<div><b>${interval ? interval.toFixed(interval % 1 ? 1 : 0) + ' min' : '—'}</b><span>Report interval</span></div>
<div><b>${t ? t.n.toLocaleString() : '—'}</b><span>Readings today</span></div>
<div><b>${cov != null ? cov.toFixed(0) + '%' : '—'}</b><span>30-day coverage</span>${cov != null ? `<div class="bar-meter"><i style="width:${cov.toFixed(0)}%"></i></div>` : ''}</div>
<div><b>${DAILY.length}</b><span>Days recorded</span><small>${DAILY[0] ? 'since ' + prettyDay(DAILY[0].d) : ''}</small></div>
</div></div>`;
}
function ordinal(n) { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); }
function renderHighlights() {
const host = $('highlights');
if (!host) return;
const t = todayRow();
const hist = DAILY.filter((d) => d.d !== todayStr());
if (!t || hist.length < 7) { host.innerHTML = ''; return; }
const chips = [];
const check = (key, label, better, minVal, icon, cool, unit, d = 1, partial = true) => {
const v = t[key];
if (num(v) == null || (minVal != null && v < minVal)) return;
const others = hist.filter((x) => num(x[key]) != null);
if (others.length < 7) return;
const rank = others.filter((x) => better(x[key], v)).length + 1;
const val = `${v.toFixed(d)}${unit}`;
const so = partial ? ' so far' : '';
if (rank === 1) { chips.push({ t: `${icon} ${label} on record (${val}${so})`, cool }); return; }
if (rank <= 3) { chips.push({ t: `${icon} ${ordinal(rank)} ${label.toLowerCase()} on record (${val}${so})`, cool }); return; }
let since = 0;
for (let i = hist.length - 1; i >= 0; i--) { if (num(hist[i][key]) != null && !better(v, hist[i][key]) && hist[i][key] !== v) { since = hist.length - i; break; } }
if (since >= 14) chips.push({ t: `${icon} ${label} in ${since} days (${val}${so})`, cool });
};
check('hi', 'Hottest day', (a, b) => a >= b, null, '🔥', false, '°');
check('lo', 'Coldest night', (a, b) => a <= b, null, '❄️', true, '°', 1, true);
check('gust', 'Windiest day', (a, b) => a >= b, 10, '💨', true, ' mph');
check('rain', 'Wettest day', (a, b) => a >= b, 0.1, '🌧', true, '"', 2);
host.innerHTML = chips.slice(0, 4).map((c) => `<span class="chip${c.cool ? ' cool' : ''}">${c.t}</span>`).join('');
}
// ── Rain tab ─────────────────────────────────────────────────────────────────
function windowDays(n) {
const end = todayStr();
const map = new Map(DAILY.map((d) => [d.d, d]));
const out = [];
for (let i = n - 1; i >= 0; i--) { const s = addDays(end, -i); out.push({ d: s, row: map.get(s) || null }); }
return out;
}
function renderRain() {
const host = $('rainTab');
if (!host) return;
if (!DAILY.length) { host.innerHTML = '<div class="status">No daily data recorded yet.</div>'; return; }
const win = windowDays(rainRange).filter((x, i, a) => x.row || i >= a.findIndex((y) => y.row));
const known = win.filter((x) => x.row && num(x.row.rain) != null);
const total = sum(known.map((x) => x.row.rain));
const rainy = known.filter((x) => x.row.rain >= 0.01);
const wettest = known.reduce((b, x) => (!b || x.row.rain > b.row.rain ? x : b), null);
let dry = 0, run = 0;
for (const x of win) { if (x.row && x.row.rain < 0.01) { run++; dry = Math.max(dry, run); } else run = 0; }
host.innerHTML = `
<div class="range-bar" id="rainRange"><span>Window:</span>
${[30, 90, 365].map((n) => `<button class="range-btn${n === rainRange ? ' active' : ''}" data-rr="${n}">${n === 365 ? '1y' : n + 'd'}</button>`).join('')}</div>
<div class="stats secondary" style="margin-bottom:1rem">
<div class="stat"><div class="stat-label">Total</div><div class="stat-val">${total.toFixed(2)}</div><div class="stat-unit">in</div></div>
<div class="stat"><div class="stat-label">Rainy days</div><div class="stat-val">${rainy.length}</div><div class="stat-unit">of ${known.length} days</div></div>
<div class="stat"><div class="stat-label">Wettest day</div><div class="stat-val">${wettest ? wettest.row.rain.toFixed(2) : '—'}</div><div class="stat-unit">${wettest ? prettyDay(wettest.d) : ''}</div></div>
<div class="stat"><div class="stat-label">Avg / rainy day</div><div class="stat-val">${rainy.length ? (total / rainy.length).toFixed(2) : '—'}</div><div class="stat-unit">in</div></div>
<div class="stat"><div class="stat-label">Longest dry spell</div><div class="stat-val">${dry}</div><div class="stat-unit">days</div></div>
</div>
<div class="charts">
<div class="chart-card wide"><div class="chart-title">Daily rain &amp; cumulative total</div><div class="chart-wrap"><canvas id="rnDaily"></canvas></div></div>
<div class="chart-card wide"><div class="chart-title">Monthly totals</div><div class="chart-wrap" style="height:200px"><canvas id="rnMonthly"></canvas></div></div>
</div>`;
host.querySelectorAll('[data-rr]').forEach((b) => b.addEventListener('click', () => { rainRange = +b.dataset.rr; renderRain(); }));
const c = chartColors();
let cum = 0;
const cumul = win.map((x) => { if (x.row && num(x.row.rain) != null) cum += x.row.rain; return +cum.toFixed(2); });
makeChart('rnDaily', 'bar', [
{ ...barDataset('Daily rain (in)', win.map((x) => (x.row ? x.row.rain : null)), c.cool), yAxisID: 'y' },
{ type: 'line', label: 'Cumulative (in)', data: cumul, borderColor: c.accent, backgroundColor: c.accent + '00', borderWidth: 2, pointRadius: 0, tension: 0.2, yAxisID: 'y2' },
], win.map((x) => prettyDay(x.d)), 'in/day', {
scales: { y: { min: 0 }, y2: { position: 'right', min: 0, grid: { drawOnChartArea: false }, ticks: { color: c.inkLight, font: { size: 11 } } } },
});
const byMonth = {};
for (const d of DAILY) if (num(d.rain) != null) { const m = d.d.slice(0, 7); byMonth[m] = (byMonth[m] || 0) + d.rain; }
const months = Object.keys(byMonth).sort();
makeChart('rnMonthly', 'bar', [barDataset('Total rain (in)', months.map((m) => +byMonth[m].toFixed(2)), c.cool)],
months.map(prettyMonth), 'in', { scales: { y: { min: 0 } } });
}
// ── Calendar (Records tab) ───────────────────────────────────────────────────
const CAL = {
hi: { label: 'High temp', get: (d) => d.hi, color: (v) => { if (v == null) return 'var(--border)'; const n = Math.max(0, Math.min(1, (v - 20) / 90)); return `rgb(${Math.round(42 + n * 182)},${Math.round(107 + n * 13)},${Math.round(138 - n * 96)})`; }, fmt: (v) => v.toFixed(1) + '°F', legend: [20, 40, 60, 80, 100] },
lo: { label: 'Low temp', get: (d) => d.lo, color: (v) => CAL.hi.color(v), fmt: (v) => v.toFixed(1) + '°F', legend: [20, 40, 60, 80, 100] },
rain: { label: 'Rain', get: (d) => d.rain, color: (v) => (v == null ? 'var(--border)' : v < 0.01 ? 'color-mix(in srgb, var(--border) 60%, transparent)' : `color-mix(in srgb, var(--cool) ${Math.round(25 + Math.min(1, v / 1.5) * 75)}%, transparent)`), fmt: (v) => v.toFixed(2) + '"', legend: [0, 0.1, 0.4, 0.8, 1.5] },
gust: { label: 'Peak gust', get: (d) => d.gust, color: (v) => (v == null ? 'var(--border)' : `color-mix(in srgb, var(--accent) ${Math.round(10 + Math.min(1, v / 40) * 90)}%, transparent)`), fmt: (v) => v.toFixed(0) + ' mph', legend: [0, 10, 20, 30, 40] },
};
function renderCalendar() {
const host = $('calendarCard');
if (!host) return;
if (!DAILY.length) { host.innerHTML = '<div class="status">No daily data yet.</div>'; return; }
const M = CAL[calMetric];
const map = new Map(DAILY.map((d) => [d.d, d]));
const end = todayStr();
let start = addDays(end, -364);
const dow = new Date(dayMs(start)).getUTCDay();
start = addDays(start, -dow);
const cells = [], labels = [];
let col = 0, lastMonth = '';
for (let s = start, i = 0; s <= end; s = addDays(s, 1), i++) {
if (i % 7 === 0) {
col = i / 7;
const m = s.slice(0, 7);
if (m !== lastMonth && s >= addDays(end, -364)) { labels.push(`<span style="grid-column:${col + 1}">${new Date(dayMs(s)).toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' })}</span>`); lastMonth = m; }
}
const row = map.get(s), v = row ? M.get(row) : null;
const before = s < addDays(end, -364);
cells.push(`<div class="heatmap-cell${s === end ? ' today' : ''}" style="background:${before ? 'transparent' : M.color(num(v))}" title="${prettyDay(s)}${num(v) != null ? ': ' + M.fmt(v) : ''}"></div>`);
}
host.innerHTML = `<div class="section-card-hdr">Calendar</div>
<div class="cal-bar">${Object.entries(CAL).map(([k, m]) => `<button class="range-btn${k === calMetric ? ' active' : ''}" data-cal="${k}">${m.label}</button>`).join('')}</div>
<div class="heatmap-wrap"><div class="cal-months">${labels.join('')}</div><div class="heatmap">${cells.join('')}</div></div>
<div class="heatmap-legend"><span>Low</span>${M.legend.map((v) => `<div class="heatmap-legend-swatch" style="background:${M.color(v)}"></div>`).join('')}<span>High</span></div>`;
host.querySelectorAll('[data-cal]').forEach((b) => b.addEventListener('click', () => { calMetric = b.dataset.cal; renderCalendar(); }));
const wrap = host.querySelector('.heatmap-wrap');
if (wrap) wrap.scrollLeft = wrap.scrollWidth; // show the most recent weeks on phones
}
// ── Insights tab ─────────────────────────────────────────────────────────────
function drawRose(canvas, buckets) {
const dpr = window.devicePixelRatio || 1, S = 170;
canvas.width = S * dpr; canvas.height = S * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const cs = getComputedStyle(document.documentElement);
const border = cs.getPropertyValue('--border').trim(), accent = cs.getPropertyValue('--accent').trim(), light = cs.getPropertyValue('--ink-light').trim();
const cx = S / 2, R = S / 2 - 18, max = Math.max(...buckets, 1), sl = (Math.PI * 2) / 16;
for (let i = 1; i <= 3; i++) { ctx.beginPath(); ctx.arc(cx, cx, (R * i) / 3, 0, Math.PI * 2); ctx.strokeStyle = border + '88'; ctx.lineWidth = 0.6; ctx.stroke(); }
ctx.fillStyle = light; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
['N', 'E', 'S', 'W'].forEach((l, i) => { const a = (i * Math.PI) / 2 - Math.PI / 2; ctx.fillText(l, cx + Math.cos(a) * (R + 10), cx + Math.sin(a) * (R + 10)); });
buckets.forEach((b, i) => {
const len = (b / max) * R; if (len < 1) return;
const a = i * sl - Math.PI / 2 - sl / 2;
ctx.beginPath(); ctx.moveTo(cx, cx); ctx.arc(cx, cx, len, a, a + sl); ctx.closePath();
ctx.fillStyle = accent + 'cc'; ctx.fill(); ctx.strokeStyle = accent; ctx.lineWidth = 0.5; ctx.stroke();
});
}
function seasonOf(month, south) {
const m = +month;
const s = m === 12 || m <= 2 ? 0 : m <= 5 ? 1 : m <= 8 ? 2 : 3; // DJF MAM JJA SON
return south ? (s + 2) % 4 : s;
}
function trendRow(label, cur, prev, d, unit, goodUp) {
if (cur == null || prev == null) return `<div class="cmp-row"><span>${label}</span><b>—</b></div>`;
const diff = cur - prev;
const cls = Math.abs(diff) < Math.pow(10, -d) ? 'flat' : diff > 0 ? 'up' : 'down';
return `<div class="cmp-row"><span>${label}</span><span><b>${cur.toFixed(d)}${unit}</b> <span class="delta ${cls}" style="display:inline">${cls === 'flat' ? '=' : (diff > 0 ? '▲ ' : '▼ ') + Math.abs(diff).toFixed(d) + unit}</span></span></div>`;
}
function renderInsights() {
const host = $('insightsBody');
if (!host) return;
if (DAILY.length < 3) { host.innerHTML = '<div class="status">Insights unlock after a few days of recorded data.</div>'; return; }
const past = DAILY.filter((d) => d.d !== todayStr());
const W = Math.min(30, Math.floor(past.length / 2));
let compare = '<p class="note">Needs at least 14 recorded days to compare periods.</p>';
if (W >= 7) {
const cur = past.slice(-W), prev = past.slice(-2 * W, -W);
const col = (a, k) => a.map((x) => x[k]).filter((v) => num(v) != null);
compare = `<p class="note" style="margin-bottom:0.5rem">Last ${W} days vs the ${W} before</p>
${trendRow('Avg temp', mean(col(cur, 'avg')), mean(col(prev, 'avg')), 1, '°')}
${trendRow('Avg high', mean(col(cur, 'hi')), mean(col(prev, 'hi')), 1, '°')}
${trendRow('Avg low', mean(col(cur, 'lo')), mean(col(prev, 'lo')), 1, '°')}
${trendRow('Rain total', sum(col(cur, 'rain')), sum(col(prev, 'rain')), 2, '"')}
${trendRow('Rainy days', col(cur, 'rain').filter((v) => v >= 0.01).length, col(prev, 'rain').filter((v) => v >= 0.01).length, 0, '')}
${trendRow('Peak gust', Math.max(...col(cur, 'gust'), 0), Math.max(...col(prev, 'gust'), 0), 0, ' mph')}`;
}
// Normals: this calendar month vs the same month in earlier years
const thisMonth = todayStr().slice(5, 7), thisYear = todayStr().slice(0, 4);
const monthRows = (y) => DAILY.filter((d) => d.d.startsWith(`${y}-${thisMonth}`));
const years = [...new Set(DAILY.map((d) => d.d.slice(0, 4)))].filter((y) => y !== thisYear && monthRows(y).length >= 20);
const curRows = monthRows(thisYear);
let normals = `<p class="note">Station normals unlock once a full year of data is recorded — ${DAILY.length} day${DAILY.length === 1 ? '' : 's'} so far. Until then, use the period comparison.</p>`;
if (years.length && curRows.length) {
const avgOf = (y, k) => mean(monthRows(y).map((d) => d[k]).filter((v) => num(v) != null));
const normAvg = (k) => mean(years.map((y) => avgOf(y, k)).filter((v) => v != null));
const rainNorm = mean(years.map((y) => sum(monthRows(y).map((d) => d.rain || 0))));
normals = `<p class="note" style="margin-bottom:0.5rem">This month vs ${years.length}-year station average</p>
${trendRow('Avg temp', mean(curRows.map((d) => d.avg).filter((v) => v != null)), normAvg('avg'), 1, '°')}
${trendRow('Avg high', mean(curRows.map((d) => d.hi).filter((v) => v != null)), normAvg('hi'), 1, '°')}
${trendRow('Avg low', mean(curRows.map((d) => d.lo).filter((v) => v != null)), normAvg('lo'), 1, '°')}
${trendRow('Rain (month to date vs full month avg)', sum(curRows.map((d) => d.rain || 0)), rainNorm, 2, '"')}`;
}
host.innerHTML = `
<div class="insight-grid">
<div class="ui-card"><h3>Trends</h3>${compare}</div>
<div class="ui-card"><h3>Vs normal</h3>${normals}</div>
</div>
<div class="charts" style="margin-bottom:1rem">
<div class="chart-card wide"><div class="chart-title">Daily temperature range (last 60 days)</div><div class="chart-wrap"><canvas id="inDiurnal"></canvas></div></div>
<div class="chart-card wide"><div class="chart-title">Data coverage by day</div><div class="chart-wrap" style="height:150px"><canvas id="inCoverage"></canvas></div></div>
</div>
<div class="ui-card" style="margin-bottom:1rem"><h3>Prevailing wind by season</h3><div class="roses" id="seasonRoses"></div>
<p class="note" style="margin-top:0.7rem">Share of readings with measurable wind, by direction it blows from.</p></div>`;
const c = chartColors();
const last60 = past.slice(-60);
makeChart('inDiurnal', 'bar', [
{ label: 'Low–High', data: last60.map((d) => (d.lo != null && d.hi != null ? [d.lo, d.hi] : null)), backgroundColor: c.accent + '77', borderColor: c.accent, borderWidth: 1, borderRadius: 3, borderSkipped: false },
{ type: 'line', label: 'Average', data: last60.map((d) => d.avg), borderColor: c.cool, backgroundColor: c.cool + '00', borderWidth: 2, pointRadius: 0, tension: 0.3 },
], last60.map((d) => prettyDay(d.d)), '°F', (() => {
const los = last60.map((d) => d.lo).filter((v) => v != null), his = last60.map((d) => d.hi).filter((v) => v != null);
return los.length ? { scales: { y: { min: Math.floor(Math.min(...los) - 3), max: Math.ceil(Math.max(...his) + 3) } } } : {};
})());
const interval = medianIntervalMin();
const perDay = interval ? 1440 / interval : null;
const cov = past.slice(-45);
if (perDay) {
const vals = cov.map((d) => +coverageFor(d, perDay).toFixed(0));
const ds = barDataset('Coverage %', vals, c.cool);
ds.backgroundColor = vals.map((v) => (v < 90 ? c.accent + 'bb' : c.cool + 'bb'));
makeChart('inCoverage', 'bar', [ds], cov.map((d) => prettyDay(d.d)), '%', { scales: { y: { min: 0, max: 100 } } });
}
const south = LOC && LOC.lat < 0;
const names = south ? ['Summer', 'Autumn', 'Winter', 'Spring'] : ['Winter', 'Spring', 'Summer', 'Fall'];
const seasons = names.map(() => new Array(16).fill(0));
for (const d of DAILY) if (d.wd) { const s = seasonOf(d.d.slice(5, 7), south); d.wd.forEach((v, i) => { seasons[s][i] += v; }); }
const roses = $('seasonRoses');
const cards = seasons.map((b, i) => ({ name: names[i], b, total: sum(b) })).filter((x) => x.total > 0);
if (!cards.length) roses.innerHTML = '<p class="note">Wind direction history builds up as new readings are saved.</p>';
cards.forEach((x) => {
const div = document.createElement('div');
const top = x.b.indexOf(Math.max(...x.b));
div.innerHTML = `<canvas></canvas><small><b>${x.name}</b> · from ${cardinal(top * 22.5)} (${Math.round((x.b[top] / x.total) * 100)}%)</small>`;
roses.appendChild(div);
drawRose(div.querySelector('canvas'), x.b);
});
}
// ── Who's signed in: role-aware header links ─────────────────────────────────
(async function roleAwareHeader() {
try {
const me = await getJSON('/api/dashboard/me');
const right = document.querySelector('.header-right');
if (!right) return;
const settings = right.querySelector('a[href="/dashboard/settings"]');
if (me.role !== 'admin' && settings) settings.remove();
const acct = document.createElement('a');
acct.href = '/dashboard/account'; acct.className = 'logout'; acct.textContent = me.role === 'admin' ? 'Account & Users' : 'Account';
acct.title = `Signed in as ${me.username} (${me.role})`;
(settings && me.role === 'admin' ? settings : right.querySelector('a[href="/dashboard/logout"]'))?.before(acct);
const nm = $('stationName');
if (nm && !nm.textContent) nm.textContent = me.username;
window.DASH_USER = me;
} 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, 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; NWS = nws;
LATEST = RECENT[RECENT.length - 1] || null;
const allBtn = document.querySelector('.range-btn[data-range="all"]');
if (allBtn && DAILY[0]) {
const first = new Date(dayMs(DAILY[0].d)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
allBtn.title = `Everything recorded since ${first}`;
}
if (LATEST?.tz) TZ = LATEST.tz;
if (!LOC) {
const loc = await getJSON('/api/dashboard/location').catch(() => null);
if (loc && num(loc.lat) != null) {
LOC = loc; window.STATION_LOC = loc;
if (typeof loadHistory === 'function') loadHistory(currentRange); // redraw so clear-sky overlay appears
}
}
renderNowExtras();
rendered.rain = rendered.cal = rendered.insights = false;
renderTab(activeTab());
} catch (e) { if (e && e.message !== 'auth') console.error('[dashboard-ui]', e); }
}
document.getElementById('refreshBtn')?.addEventListener('click', refreshData);
setInterval(refreshData, 120000);
let initial = location.hash.slice(1);
if (!TABS.includes(initial)) { try { initial = sessionStorage.getItem('dashTab') || 'now'; } catch { initial = 'now'; } }
setTab(initial, false);
refreshData();
})();