From 00336c6ce1b86994131bc84f5a60dba5067ad776 Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Thu, 15 Oct 2026 13:17:33 -0400 Subject: [PATCH] Add 'All' history range to the dashboard and pressure tendency chart - /api/station/history?range=all returns everything on disk, bucketed (cached 60s) - Year-aware axis labels for 1y/2y/3y/All - Pressure tendency widens its change window to the bucket size on long ranges and says so Co-Authored-By: Claude Sonnet 5.5 --- app/public/dashboard.html | 23 +++++++++++++++-------- app/server.js | 26 +++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/app/public/dashboard.html b/app/public/dashboard.html index 2ffe0fd..73e8410 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -551,6 +551,7 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */ + @@ -612,7 +613,10 @@ let charts = {}; function fmtTime(ts, range) { const d = new Date(ts); if (range === '2d') return d.toLocaleDateString('en-US', { weekday: 'short' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true }); - if (['7d','30d','90d','1y','2y','3y'].includes(range)) { + if (['1y','2y','3y','all'].includes(range)) { + return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); + } + if (['7d','30d','90d'].includes(range)) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); @@ -796,7 +800,7 @@ async function drawPresTend(pagePts, pageRange) { if (hit && Date.now() - hit.at < 120000) pts = hit.data; else { try { - const bucket = ['7d', '30d'].includes(range) ? '&points=720' : ''; + const bucket = ['7d', '30d', 'all'].includes(range) ? '&points=720' : ''; const r = await fetch(`/api/station/history?range=${range}${bucket}`); pts = r.ok ? await r.json() : []; } catch { pts = []; } @@ -805,7 +809,10 @@ async function drawPresTend(pagePts, pageRange) { if (document.getElementById('cPresTend') !== canvas) return; // charts were re-rendered meanwhile } if (pts.length > 900) { const step = Math.ceil(pts.length / 720); pts = pts.filter((_, i) => i % step === 0); } - const winMs = presTendWin * 3600000; + // Bucketed (long) ranges can't resolve changes shorter than one bucket: widen the window to fit + const bucketMs = pts.length > 1 ? (pts[pts.length - 1].dateutc - pts[0].dateutc) / (pts.length - 1) : 0; + const effHours = Math.max(presTendWin, Math.ceil(bucketMs / 3600000)); + const winMs = effHours * 3600000; const c = chartColors(); const tend = pts.map(r => { const t = r.dateutc - winMs; @@ -815,12 +822,12 @@ async function drawPresTend(pagePts, pageRange) { const base = pts[lo].baromrelin; return r.baromrelin != null && base != null ? +(r.baromrelin - base).toFixed(3) : null; }); - const ds = barDataset(`${presTendWin}h change`, tend, c.cool); + const ds = barDataset(`${effHours}h 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], pts.map(r => fmtTime(r.dateutc, range)), `inHg / ${presTendWin}h`); + makeChart('cPresTend', 'bar', [ds], pts.map(r => fmtTime(r.dateutc, range)), `inHg / ${effHours}h`); const title = document.getElementById('ptTitle'); - if (title) title.textContent = `Pressure Tendency · ${presTendWin}-hour change (inHg)`; + if (title) title.textContent = `Pressure Tendency · ${effHours}-hour change (inHg)${effHours !== presTendWin ? ' — widened to fit this range' : ''}`; if (!tend.some(v => v != null) && title) title.textContent += ' — not enough data for this window'; } @@ -898,7 +905,7 @@ function renderCharts(readings, range, yoy = null) { + @@ -1126,7 +1133,7 @@ async function loadHistory(range) { const area = document.getElementById('chartsArea'); area.innerHTML = '
Loading…
'; try { - const pts = ['7d','30d','90d','1y','2y','3y'].includes(range) ? '&points=720' : ''; + const pts = ['7d','30d','90d','1y','2y','3y','all'].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'); diff --git a/app/server.js b/app/server.js index 35e4a6d..662578c 100644 --- a/app/server.js +++ b/app/server.js @@ -1475,13 +1475,37 @@ function bucketReadings(readings, points) { app.get('/api/station/history', requireDashboardAuth, (req, res) => { const rangeMap = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '2d': 172800000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 }; const range = req.query.range || '24h'; + const points = parseInt(req.query.points); + + if (range === 'all') { + // Everything on disk, always bucketed (default 720 points) so the payload stays small + const first = firstStationDay(); + if (!first) return res.json([]); + const rangeAll = Date.now() - Date.parse(first + 'T00:00:00Z') + 2 * 86400000; + const pts = points >= 50 ? points : 720; + const key = `${first}|${pts}`; + if (allHistoryCache.key === key && Date.now() - allHistoryCache.at < 60000) return res.json(allHistoryCache.data); + const readingsAll = loadStationReadings(rangeAll); + const data = readingsAll.length > pts ? bucketReadings(readingsAll, pts) : readingsAll; + allHistoryCache = { key, at: Date.now(), data }; + return res.json(data); + } + const rangeMs = rangeMap[range] || rangeMap['24h']; const readings = loadStationReadings(rangeMs); - const points = parseInt(req.query.points); if (points >= 50 && readings.length > points) return res.json(bucketReadings(readings, points)); res.json(readings); }); +// Earliest day with saved readings (YYYY-MM-DD, UTC file name) +let allHistoryCache = { key: null, at: 0, data: null }; +function firstStationDay() { + try { + const files = fs.readdirSync(STATION_DIR).filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)).sort(); + return files.length ? files[0].slice(0, 10) : null; + } catch { return null; } +} + // Latest station reading app.get('/api/station/latest', requireDashboardAuth, async (req, res) => { if (!ambientServerConfig.apiKey || !ambientServerConfig.appKey) {