Redesign dashboard: tabs, insights, rain, sun/moon, zoomable charts

- Tab bar (bottom nav on phones) splitting Now / Charts / Rain / Records / Insights
- Unified tiles with vs-yesterday deltas, hero 24h trace, wind compass, today hi/lo
- Highlights chips, sun & moon card, station health card
- Charts: min/max envelope for long ranges (server-side bucketing), pinch/wheel zoom,
  pressure tendency, indoor vs outdoor, clear-sky solar overlay
- Rain tab: daily + cumulative, monthly totals; calendar heatmaps (temp/rain/gust)
- Insights: period trends, station normals, diurnal range, coverage, seasonal wind roses
- Server: /api/station/daily, history ?points= bucketing, 2d range, daily avg + wind
  histogram aggregates; fix dry days never recording rain=0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 12:25:08 -04:00
1 parent a97d104d14
commit 1ad2dbb860
4 files changed
+853 -83

No files matched your search

+100 -78
View File
@@ -13,6 +13,9 @@
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<title>Station Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
<link rel="stylesheet" href="/dashboard-ui.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
@@ -388,10 +391,20 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
</div>
</div>
<nav class="tabbar" id="tabbar" aria-label="Dashboard sections">
<button class="tab active" data-tab="now"><span class="tab-ico">◉</span><span>Now</span></button>
<button class="tab" data-tab="charts"><span class="tab-ico">〰</span><span>Charts</span></button>
<button class="tab" data-tab="rain"><span class="tab-ico">☂</span><span>Rain</span></button>
<button class="tab" data-tab="records"><span class="tab-ico">★</span><span>Records</span></button>
<button class="tab" data-tab="insights"><span class="tab-ico">✦</span><span>Insights</span></button>
</nav>
<div class="page">
<div class="offline-banner" id="offlineBanner"></div>
<div id="alertsSection" style="margin-bottom:0"></div>
<div class="tab-pane active" data-pane="now">
<div id="highlights" class="highlights"></div>
<!-- Hero: Current Conditions -->
<div class="section-card">
<div class="section-card-hdr">Current Conditions</div>
@@ -521,6 +534,10 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
</div>
</div>
<div id="sunHealthRow" class="two-col"><div id="sunCard"></div><div id="healthCard"></div></div>
</div><!-- /now -->
<div class="tab-pane" data-pane="charts">
<!-- Range selector -->
<div class="range-bar">
<span>History:</span>
@@ -543,6 +560,11 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
<div class="status" id="loadingMsg">Loading history…</div>
</div>
</div><!-- /charts -->
<div class="tab-pane" data-pane="rain" id="rainTab"><div class="status">Loading rain…</div></div>
<div class="tab-pane" data-pane="records">
<!-- Records -->
<details class="settings-panel" id="recordsPanel" style="margin-top:1.5rem">
<summary>All-Time Station Records</summary>
@@ -559,14 +581,12 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
</div>
</details>
<!-- Temperature Heatmap -->
<details class="settings-panel" id="heatmapPanel" style="margin-top:1rem">
<summary>Temperature Calendar</summary>
<div class="settings-body" id="heatmapBody">
<div class="status">Loading…</div>
</div>
</details>
<div id="calendarCard" class="section-card" style="margin-top:1rem"><div class="status">Loading calendar…</div></div>
</div><!-- /records -->
<div class="tab-pane" data-pane="insights" id="insightsTab">
<div id="insightsBody"><div class="status">Loading insights…</div></div>
<!-- Alert History -->
<details class="settings-panel" id="alertHistoryPanel" style="margin-top:1rem">
<summary>Alert History</summary>
@@ -574,6 +594,7 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
<div class="status">Loading…</div>
</div>
</details>
</div><!-- /insights -->
</div>
@@ -626,6 +647,10 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
zoom: {
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x', onZoomComplete: ({ chart }) => markZoomed(chart) },
pan: { enabled: true, mode: 'x', threshold: 10, onPanComplete: ({ chart }) => markZoomed(chart) },
},
legend: { display: datasets.length > 1, labels: { color: c.inkLight, font: { size: 11 }, boxWidth: 10 } },
tooltip: {
backgroundColor: 'rgba(0,0,0,0.75)',
@@ -650,9 +675,23 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
const mergedOpts = deepMerge(defaults, opts);
charts[canvasId] = new Chart(ctx, { type, data: { labels, datasets }, options: mergedOpts });
ctx.ondblclick = () => { charts[canvasId].resetZoom(); markZoomed(charts[canvasId], false); };
return charts[canvasId];
}
function markZoomed(chart, on = true) {
const card = chart.canvas.closest('.chart-card');
if (!card) return;
let btn = card.querySelector('.chart-reset');
if (!btn) {
btn = document.createElement('button');
btn.className = 'chart-reset'; btn.textContent = 'Reset zoom';
btn.addEventListener('click', () => { chart.resetZoom(); markZoomed(chart, false); });
card.querySelector('.chart-title')?.appendChild(btn);
}
btn.style.display = on ? '' : 'none';
}
function deepMerge(a, b) {
const out = { ...a };
for (const k of Object.keys(b || {})) {
@@ -794,6 +833,11 @@ function renderCharts(readings, range, yoy = null) {
<div class="chart-wrap"><canvas id="cPres"></canvas></div>
</div>
<div class="chart-card wide" id="cardPresTend">
<div class="chart-title">Pressure Tendency · 3-hour change (inHg)</div>
<div class="chart-wrap" style="height:140px"><canvas id="cPresTend"></canvas></div>
</div>
<!-- Rain -->
<div class="chart-card">
<div class="chart-title">Rain Rate (in/hr)</div>
@@ -817,18 +861,27 @@ function renderCharts(readings, range, yoy = null) {
<div class="chart-wrap"><canvas id="cSolar"></canvas></div>
</div>
${readings.some(r => r.tempinf != null) ? `<div class="chart-card wide">
<div class="chart-title">Indoor vs Outdoor</div>
<div class="chart-wrap"><canvas id="cIndoor"></canvas></div>
</div>` : ''}
</div>
`;
const tempDatasets = [
lineDataset('Temp °F', field(pts, 'tempf'), c.accent),
lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true),
];
const tempDatasets = [];
if (pts.some(r => r.tempMax != null)) {
// Bucketed long ranges: show the true min/max envelope behind the average
tempDatasets.push({ label: 'Max', data: field(pts, 'tempMax'), borderWidth: 0, pointRadius: 0, backgroundColor: c.accent + '22', fill: '+1', tension: 0.3 });
tempDatasets.push({ label: 'Min', data: field(pts, 'tempMin'), borderWidth: 0, pointRadius: 0, backgroundColor: c.accent + '22', fill: false, tension: 0.3 });
}
tempDatasets.push(lineDataset('Temp °F', field(pts, 'tempf'), c.accent));
tempDatasets.push(lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true));
if (yoy?.prior?.length) {
const yoyPts = yoy.prior.length > 720 ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / 720) === 0) : yoy.prior;
tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
}
makeChart('cTemp', 'line', tempDatasets, lbls, '°F');
makeChart('cTemp', 'line', tempDatasets, lbls, '°F', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
makeChart('cHum', 'line',
[lineDataset('Humidity %', field(pts, 'humidity'), c.cool),
@@ -860,13 +913,38 @@ function renderCharts(readings, range, yoy = null) {
[lineDataset('UV Index', field(pts, 'uv'), c.accent)],
lbls, null, { scales: { y: { min: 0 } } });
makeChart('cSolar', 'line',
[lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)],
lbls, 'W/m²', { scales: { y: { min: 0 } } });
const solarSets = [lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)];
if (typeof clearSkyGHI === 'function' && ['1h', '6h', '24h', '2d', '7d'].includes(range) && window.STATION_LOC) {
const cs = pts.map(r => clearSkyGHI(r.dateutc));
if (cs.some(v => v != null)) solarSets.push(lineDataset('Clear-sky potential', cs, c.cool, true));
}
makeChart('cSolar', 'line', solarSets, lbls, 'W/m²', { scales: { y: { min: 0 } } });
// Pressure tendency: change vs 3 hours earlier (what forecasters watch)
if (hasPres) {
const tend = pts.map(r => {
const t = r.dateutc - 3 * 3600000;
let lo = 0, hi = pts.length - 1;
if (t < pts[0].dateutc) return null;
while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m].dateutc < t) lo = m + 1; else hi = m; }
const base = pts[lo].baromrelin;
return r.baromrelin != null && base != null ? +(r.baromrelin - base).toFixed(3) : null;
});
const ds = barDataset('3h change', tend, c.cool);
ds.backgroundColor = tend.map(v => v == null ? 'transparent' : (v >= 0 ? c.cool + 'bb' : c.accent + 'bb'));
ds.borderWidth = 0;
makeChart('cPresTend', 'bar', [ds], lbls, 'inHg / 3h');
}
// Indoor vs outdoor
if (pts.some(r => r.tempinf != null)) {
makeChart('cIndoor', 'line',
[lineDataset('Outdoor °F', field(pts, 'tempf'), c.accent),
lineDataset('Indoor °F', field(pts, 'tempinf'), c.cool),
lineDataset('Indoor humidity %', field(pts, 'humidityin'), c.cool, true)],
lbls, null);
}
// Today bar + sparklines use full readings (not downsampled pts)
updateTodayBar(readings);
updateSparklines(readings);
// Wind rose
const roseCanvas = document.getElementById('windRoseCanvas');
@@ -993,7 +1071,8 @@ async function loadHistory(range) {
const area = document.getElementById('chartsArea');
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
try {
const r = await fetch(`/api/station/history?range=${range}`);
const pts = ['7d','30d','90d','1y','2y','3y'].includes(range) ? '&points=720' : '';
const r = await fetch(`/api/station/history?range=${range}${pts}`);
if (r.status === 401) { location.href = '/dashboard/login'; return; }
if (!r.ok) throw new Error('Failed');
const data = await r.json();
@@ -1001,7 +1080,7 @@ async function loadHistory(range) {
const line = document.getElementById('updatedLine');
if (line) {
const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
line.textContent = `${data.length} readings · updated ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · next refresh ${next}`;
line.textContent = `${data.length}${data.some(d => d.n) ? ' time buckets' : ' readings'} · pinch / scroll to zoom · double-tap to reset · updated ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · next refresh ${next}`;
}
} catch (e) {
area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`;
@@ -1251,64 +1330,6 @@ if (monthlyPanel) {
}, { once: false });
}
// ── Temperature heatmap ───────────────────────────────────────────────────────
const heatmapPanel = document.getElementById('heatmapPanel');
if (heatmapPanel) {
heatmapPanel.addEventListener('toggle', async () => {
if (!heatmapPanel.open) return;
const body = document.getElementById('heatmapBody');
if (body.querySelector('.heatmap-wrap')) return;
try {
const r = await fetch('/api/station/history?range=1y');
if (!r.ok) throw new Error();
const readings = await r.json();
if (!readings.length) { body.innerHTML = '<div class="status">No data yet.</div>'; return; }
// Daily high temp
const byDay = {};
for (const rd of readings) {
if (rd.tempf == null || !rd.dateutc) continue;
const day = new Date(rd.dateutc).toISOString().slice(0, 10);
if (byDay[day] == null || rd.tempf > byDay[day]) byDay[day] = rd.tempf;
}
// Color scale: cold = cool blue, hot = accent orange
function heatColor(t) {
if (t == null) return 'var(--border)';
const norm = Math.max(0, Math.min(1, (t - 20) / 90));
const r = Math.round(42 + norm * (224 - 42));
const g = Math.round(107 + norm * (120 - 107));
const b = Math.round(138 + norm * (42 - 138));
return `rgb(${r},${g},${b})`;
}
// Build grid: iterate days from 1yr ago to today
const today = new Date(); today.setHours(23, 59, 59, 0);
const start = new Date(today); start.setFullYear(start.getFullYear() - 1); start.setDate(start.getDate() + 1);
// Align to Sunday
const startDow = start.getDay(); // 0=Sun
const cells = [];
for (let i = 0; i < startDow; i++) cells.push(null); // padding
const cur = new Date(start);
while (cur <= today) {
const key = cur.toISOString().slice(0, 10);
cells.push({ date: key, temp: byDay[key] ?? null });
cur.setDate(cur.getDate() + 1);
}
const cellHtml = cells.map(c => {
if (!c) return `<div class="heatmap-cell" style="background:transparent"></div>`;
const t = c.temp;
const label = t != null ? `${c.date}: ${t.toFixed(1)}°F` : c.date;
return `<div class="heatmap-cell" style="background:${heatColor(t)}" title="${label}"></div>`;
}).join('');
body.innerHTML = `
<div class="heatmap-wrap"><div class="heatmap">${cellHtml}</div></div>
<div class="heatmap-legend">
<span>Cooler</span>
${[20,35,50,65,80,95,110].map(t => `<div class="heatmap-legend-swatch" style="background:${heatColor(t)}"></div>`).join('')}
<span>Hotter</span>
</div>`;
} catch { document.getElementById('heatmapBody').innerHTML = '<div class="status error">Failed to load.</div>'; }
}, { once: false });
}
// ── Tile color coding ─────────────────────────────────────────────────────────
function tempColorClass(t) {
if (t == null) return '';
@@ -1574,5 +1595,6 @@ setInterval(updateExtStats, 300000);
setInterval(updateTrends, 120000);
setInterval(loadAlerts, 5 * 60 * 1000);
</script>
<script src="/dashboard-ui.js"></script>
</body>
</html>