/* 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 === 'charts' && typeof chartsDirty !== 'undefined' && chartsDirty && typeof loadHistory === 'function') loadHistory(currentRange);
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 = '
';
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 ` `;
}).join('');
el.innerHTML = `
${ticks}
N E S W
${spd > 0.5 && dir != null ? ` ` : ''}
${spd > 0.5 ? spd.toFixed(0) : '0'}
mph${dir != null && spd > 0.5 ? ' · ' + cardinal(dir) : ''}
${gust != null && gust > spd + 1 ? `gust ${gust.toFixed(0)} ` : ''}
`;
}
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 = `Today ↑ ${fmt(t.hi)}° ↓ ${fmt(t.lo)}° `;
$('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 = `Sun & Moon Set your station location in Settings to see sunrise, sunset, day length and moon phase.
`;
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 = 'No sunrise/sunset at this latitude today.
';
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 = `
`;
const delta = stTomorrow ? (stTomorrow.set - stTomorrow.rise) - (st.set - st.rise) : null;
body = `
${clock(st.rise)} Sunrise
${clock(st.set)} Sunset
${hm(st.set - st.rise)} Daylight ${delta != null ? `${delta >= 0 ? '+' : '−'}${Math.abs(Math.round(delta / 1000))}s tomorrow ` : ''}
${clock(st.noon)} Solar noon sun ${el > 0 ? Math.round(el) + '° up' : 'below horizon'}
`;
}
host.innerHTML = `Sun & Moon ${arc}${body}
${moon.icon} ${Math.round(moon.illum * 100)}% ${moon.name}
${prettyDay(dayStr(now + moon.untilFull * MS_DAY))} Next full
${prettyDay(dayStr(now + moon.untilNew * MS_DAY))} Next new
`;
}
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 = `Station Health
${ok ? 'Online' : 'Delayed'} Last report ${ageMin <= 0 ? 'just now' : ageMin + ' min ago'}
${LATEST.battout == null ? '—' : LATEST.battout === 1 ? 'OK' : 'Low'} Battery
${interval ? interval.toFixed(interval % 1 ? 1 : 0) + ' min' : '—'} Report interval
${t ? t.n.toLocaleString() : '—'} Readings today
${cov != null ? cov.toFixed(0) + '%' : '—'} 30-day coverage ${cov != null ? `
` : ''}
${DAILY.length} Days recorded ${DAILY[0] ? 'since ' + prettyDay(DAILY[0].d) : ''}
`;
}
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) => `${c.t} `).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 = 'No daily data recorded yet.
'; 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 = `
Window:
${[30, 90, 365].map((n) => `${n === 365 ? '1y' : n + 'd'} `).join('')}
Total
${total.toFixed(2)}
in
Rainy days
${rainy.length}
of ${known.length} days
Wettest day
${wettest ? wettest.row.rain.toFixed(2) : '—'}
${wettest ? prettyDay(wettest.d) : ''}
Avg / rainy day
${rainy.length ? (total / rainy.length).toFixed(2) : '—'}
in
Longest dry spell
${dry}
days
Daily rain & cumulative total
`;
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 = 'No daily data yet.
'; 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(`${new Date(dayMs(s)).toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' })} `); lastMonth = m; }
}
const row = map.get(s), v = row ? M.get(row) : null;
const before = s < addDays(end, -364);
cells.push(`
`);
}
host.innerHTML = `Calendar
${Object.entries(CAL).map(([k, m]) => `${m.label} `).join('')}
${labels.join('')}
${cells.join('')}
Low ${M.legend.map((v) => `
`).join('')}
High `;
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 `${label} —
`;
const diff = cur - prev;
const cls = Math.abs(diff) < Math.pow(10, -d) ? 'flat' : diff > 0 ? 'up' : 'down';
return `${label} ${cur.toFixed(d)}${unit} ${cls === 'flat' ? '=' : (diff > 0 ? '▲ ' : '▼ ') + Math.abs(diff).toFixed(d) + unit}
`;
}
function renderInsights() {
const host = $('insightsBody');
if (!host) return;
if (DAILY.length < 3) { host.innerHTML = 'Insights unlock after a few days of recorded data.
'; return; }
const past = DAILY.filter((d) => d.d !== todayStr());
const W = Math.min(30, Math.floor(past.length / 2));
let compare = 'Needs at least 14 recorded days to compare periods.
';
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 = `Last ${W} days vs the ${W} before
${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 = `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.
`;
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 = `This month vs ${years.length}-year station average
${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 = `
Trends ${compare}
Vs normal ${normals}
Daily temperature range (last 60 days)
Prevailing wind by season
Share of readings with measurable wind, by direction it blows from.
`;
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 = 'Wind direction history builds up as new readings are saved.
';
cards.forEach((x) => {
const div = document.createElement('div');
const top = x.b.indexOf(Math.max(...x.b));
div.innerHTML = `${x.name} · from ${cardinal(top * 22.5)} (${Math.round((x.b[top] / x.total) * 100)}%) `;
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"]');
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 || 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 = `Official NWS observation ${n.lastError ? 'Could not reach the National Weather Service yet: ' + n.lastError : 'Waiting for the first observation from the National Weather Service…'}${r ? ` Station ${r.stationId} for ZIP ${r.zip}.` : ''}
`;
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 ${Math.abs(d).toFixed(1)}°F ${d > 0 ? 'warmer' : 'cooler'} `);
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 = `Official NWS observation
${esc2(r?.stationId || l.station)} ${esc2(r?.stationName || '')} · ${r?.distanceMi != null ? r.distanceMi + ' mi from ' : 'for '}ZIP ${esc2(r?.zip || '')}${r?.place ? ' (' + esc2(r.place) + ')' : ''}
${f(l.tempf, 1, '°F')} Temperature
${f(l.dewPoint, 0, '°F')} Dew point
${f(l.humidity, 0, '%')} Humidity
${wind} Wind
${f(l.baromrelin, 2, ' inHg')} Pressure
${f(l.visibilityMi, 0, ' mi')} Visibility
${esc2(l.text || '—')} Conditions
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 ? `${esc2(l.raw)}` : ''}
`;
}
const esc2 = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
})();