diff --git a/app/public/dashboard-ui.css b/app/public/dashboard-ui.css new file mode 100644 index 0000000..915f71f --- /dev/null +++ b/app/public/dashboard-ui.css @@ -0,0 +1,105 @@ +/* Dashboard UI layer: tabs, tiles, hero extras, cards, calendars */ +:root { --header-h: 64px; --tabbar-h: 54px; --radius: 14px; } + +/* ── Tabs ── */ +.tabbar { position: sticky; top: var(--header-h); z-index: 9; display: flex; gap: 0.25rem; padding: 0.4rem calc(1rem + env(safe-area-inset-left)) 0.4rem calc(1rem + env(safe-area-inset-right)); + background: color-mix(in srgb, var(--paper) 88%, transparent); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: none; } +.tabbar::-webkit-scrollbar { display: none; } +.tab { flex: 1 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem; min-height: 40px; padding: 0 1rem; border: 1px solid transparent; border-radius: 999px; + background: transparent; color: var(--ink-light); font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: background .18s, color .18s, border-color .18s; touch-action: manipulation; } +.tab:hover { color: var(--ink); background: var(--paper-dark); } +.tab.active { background: var(--accent); color: #fff; } +.tab-ico { font-size: 0.95rem; line-height: 1; } +.tab-pane { display: none; animation: pane-in .28s cubic-bezier(.2,0,0,1); } +.tab-pane.active { display: block; } +@keyframes pane-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +@media (max-width: 640px) { + /* Thumb-reach bottom navigation, safe-area aware */ + .tabbar { position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 20; border-top: 1px solid var(--border); border-bottom: none; justify-content: space-between; + padding: 0.35rem calc(0.5rem + env(safe-area-inset-right)) calc(0.35rem + env(safe-area-inset-bottom)) calc(0.5rem + env(safe-area-inset-left)); overflow: visible; background: color-mix(in srgb, var(--paper-dark) 92%, transparent); } + .tab { flex: 1 1 0; flex-direction: column; gap: 0.1rem; min-height: 50px; padding: 0 0.2rem; border-radius: 12px; font-size: 0.68rem; } + .tab-ico { font-size: 1.15rem; } + .page { padding-bottom: calc(5.5rem + env(safe-area-inset-bottom)) !important; } +} + +/* ── Unified tile language ── */ +.section-card, .chart-card, .stat, .today-bar, .settings-panel, .ui-card { border-radius: var(--radius); } +.stat { position: relative; overflow: hidden; } +.delta { font-size: 0.72rem; margin-top: 0.3rem; color: var(--ink-light); font-variant-numeric: tabular-nums; white-space: nowrap; } +.delta.up { color: var(--accent); } .delta.down { color: var(--cool); } .delta.flat { color: var(--ink-light); } +.hero-stat .delta, .hero-grid > div .delta { margin-top: 0.15rem; } +.hero-grid > div { background: var(--paper); border: 1px solid var(--border); border-radius: 12px; padding: 0.65rem 0.8rem; } + +/* ── Hero extras ── */ +.hero-main { position: relative; isolation: isolate; } +.hero-main { padding-bottom: 3.4rem; } +.hero-spark { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 44px; opacity: 0.8; pointer-events: none; border-radius: 8px; } +.hero-hilo { display: flex; gap: 0.9rem; margin-top: 0.55rem; font-size: 0.85rem; font-variant-numeric: tabular-nums; } +.hero-hilo .hi { color: var(--accent); font-weight: 700; } .hero-hilo .lo { color: var(--cool); font-weight: 700; } + +/* ── Highlights ── */ +.highlights { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; } +.highlights:empty { display: none; } +.chip { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.8rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; + background: color-mix(in srgb, var(--accent) 12%, var(--paper-dark)); border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); } +.chip.cool { background: color-mix(in srgb, var(--cool) 12%, var(--paper-dark)); border-color: color-mix(in srgb, var(--cool) 45%, var(--border)); } + +/* ── Cards & grids ── */ +.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; } +.two-col:empty { display: none; } +@media (max-width: 720px) { .two-col { grid-template-columns: 1fr; } } +.ui-card { background: var(--paper-dark); border: 1px solid var(--border); padding: 1.1rem 1.2rem; } +.ui-card h3 { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-light); margin-bottom: 0.9rem; } +.kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 0.7rem 1rem; } +.kv b { display: block; font-size: 1.15rem; line-height: 1.2; font-variant-numeric: tabular-nums; } +.kv span { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-light); } +.kv small { display: block; font-size: 0.72rem; color: var(--ink-light); } +.sun-arc { width: 100%; height: 86px; display: block; margin-bottom: 0.7rem; } +.bar-meter { height: 8px; border-radius: 99px; background: var(--border); overflow: hidden; margin-top: 0.35rem; } +.bar-meter > i { display: block; height: 100%; background: var(--cool); border-radius: 99px; } +.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 99px; font-size: 0.72rem; font-weight: 700; background: var(--paper); border: 1px solid var(--border); } +.pill.ok { color: #3ca050; border-color: #3ca05088; } .pill.warn { color: var(--accent); border-color: var(--accent); } +.insight-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem; } +.cmp-row { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; } +.cmp-row:last-child { border-bottom: none; } +.cmp-row b { font-variant-numeric: tabular-nums; } +.note { font-size: 0.78rem; color: var(--ink-light); line-height: 1.45; } +.roses { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.8rem; text-align: center; } +.roses canvas { width: 100%; max-width: 170px; height: auto; aspect-ratio: 1; margin: 0 auto; display: block; } +.roses small { display: block; font-size: 0.72rem; color: var(--ink-light); margin-top: 0.2rem; } +.chart-card .chart-title { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; } +.chart-reset { font: inherit; font-size: 0.68rem; text-transform: none; letter-spacing: 0; padding: 0.2rem 0.6rem; border-radius: 99px; border: 1px solid var(--border); background: var(--paper); color: var(--ink); cursor: pointer; min-height: 28px; } + +/* ── Wind compass ── */ +.wind-wrap { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; } +.wind-wrap .stats { flex: 1 1 280px; } +#windCompass { flex: 0 0 auto; width: 116px; height: 116px; margin: 0 auto; } + +/* ── Calendar ── */ +.cal-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.8rem; } +.cal-months { display: grid; grid-auto-flow: column; grid-auto-columns: 14px; gap: 3px; font-size: 0.62rem; color: var(--ink-light); height: 1rem; } +.heatmap-cell { cursor: default; } +.heatmap-wrap { scrollbar-width: thin; } +.heatmap-cell.today { outline: 2px solid var(--ink); outline-offset: -1px; } + +@media (max-width: 640px) { + .hero-spark { height: 40px; } + .hero-grid > div { padding: 0.55rem 0.65rem; } + .ui-card { padding: 0.9rem; } +} +@media (prefers-reduced-motion: reduce) { .tab-pane { animation: none; } } + +/* ── Phone fit: never let canvases force the grid wider than the screen ── */ +canvas.sparkline { max-width: 100%; } +.hero-grid > div, .stat, .ui-card, .chart-card { min-width: 0; } +@media (max-width: 640px) { + .hero-main { width: 100%; } + .hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 0.6rem !important; } + .hero-grid > div { padding: 0.55rem 0.65rem; } + .stats.secondary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .wind-wrap { justify-content: center; } + .wind-wrap .stats { flex-basis: 100%; } + #windCompass { width: 132px; height: 132px; } + .section-card { padding-left: 1rem; padding-right: 1rem; } +} +@media (max-width: 640px) { .header { position: static; } } diff --git a/app/public/dashboard-ui.js b/app/public/dashboard-ui.js new file mode 100644 index 0000000..94258f5 --- /dev/null +++ b/app/public/dashboard-ui.js @@ -0,0 +1,575 @@ +/* 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)); + +// 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; + return { rise: toMs(Jt - w / 360), set: toMs(Jt + w / 360), noon: toMs(Jt) }; +} +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} + + NESW + ${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(); + 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 noonsun ${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) => ``).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
+
Monthly totals
+
`; + 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]) => ``).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)
+
Data coverage by day
+
+

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); + }); +} + +// ── Data refresh ───────────────────────────────────────────────────────────── +async function refreshData() { + try { + const [daily, recent] = await Promise.all([ + getJSON('/api/station/daily').catch(() => []), + getJSON('/api/station/history?range=2d').catch(() => []), + ]); + DAILY = daily; RECENT = recent; + LATEST = RECENT[RECENT.length - 1] || null; + 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(); +})(); diff --git a/app/public/dashboard.html b/app/public/dashboard.html index 22233b3..a3ef2c2 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -13,6 +13,9 @@ Station Dashboard + + +