diff --git a/app/public/conditions.html b/app/public/conditions.html new file mode 100644 index 0000000..4b9b23b --- /dev/null +++ b/app/public/conditions.html @@ -0,0 +1,77 @@ + + + + + +Current Conditions + + + + +
+

Current Conditions

+

Personal weather station

+
+
Loading…
+
+

+
+ + + diff --git a/app/public/dashboard.html b/app/public/dashboard.html index c1e50ea..e5ca651 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -91,6 +91,18 @@ a { color: var(--cool); text-decoration: none; } .keys-status.missing { color: var(--accent); } .keys-hint { font-size: 0.78rem; color: var(--ink-light); margin-top: 1rem; line-height: 1.5; } .settings-divider { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; } +/* Records */ +.records-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.6rem; } +.record-item { background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.85rem; } +.record-label { font-size: 0.72rem; color: var(--ink-light); text-transform: uppercase; letter-spacing: 0.05em; } +.record-val { font-size: 1.15rem; font-weight: 700; margin: 0.1rem 0; } +.record-date { font-size: 0.72rem; color: var(--ink-light); } +/* Anomaly tile */ +.stat.anomaly-above { border-color: var(--accent); } +.stat.anomaly-below { border-color: var(--cool); } +/* Export link */ +.export-link { display: inline-block; padding: 0.45rem 1.2rem; background: var(--cool); color: #fff !important; border-radius: 8px; font-size: 0.9rem; font-weight: 600; text-decoration: none !important; } +.export-link:hover { opacity: 0.88; } .backfill-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } .backfill-controls { display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0; } .backfill-controls select { padding: 0.45rem 0.6rem; background: var(--paper); border: 1px solid var(--border); border-radius: 8px; color: var(--ink); font-family: var(--mono); font-size: 0.85rem; } @@ -122,7 +134,11 @@ a { color: var(--cool); text-decoration: none; }
Dew Point
—
°F
Wind
—
mph
Wind Gust
—
mph
-
Pressure
—
inHg
+
+
Pressure
+
—
+
inHg  
+
Rain Today
—
in
Rain Rate
—
in/hr
UV Index
—
@@ -130,6 +146,20 @@ a { color: var(--cool); text-decoration: none; }
Battery
—
+ +
+
Rain 7d
—
in
+
Rain 30d
—
in
+
Rain YTD
—
in
+
Heating DD
—
this month
+
Cooling DD
—
this month
+ +
+
History: @@ -143,6 +173,7 @@ a { color: var(--cool); text-decoration: none; } +
@@ -151,6 +182,14 @@ a { color: var(--cool); text-decoration: none; }
Loading history…
+ +
+ All-Time Station Records +
+
Loading records…
+
+
+
Station API Keys @@ -174,6 +213,26 @@ a { color: var(--cool); text-decoration: none; }
+
+
+
Export Data
+

Download your station history as a CSV spreadsheet.

+
+
+ + Download CSV +
+
+ +
+
@@ -370,7 +429,7 @@ function drawWindRose(canvas, readings) { } // ── Render charts ──────────────────────────────────────────────────────────── -function renderCharts(readings, range) { +function renderCharts(readings, range, yoy = null) { const area = document.getElementById('chartsArea'); if (!readings.length) { area.innerHTML = '
No recorded data for this range yet. Data is collected every minute while the station is active.
'; @@ -449,10 +508,15 @@ function renderCharts(readings, range) {
`; - makeChart('cTemp', 'line', - [lineDataset('Temp °F', field(pts, 'tempf'), c.accent), - lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true)], - lbls, '°F'); + const tempDatasets = [ + lineDataset('Temp °F', field(pts, 'tempf'), c.accent), + 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('cHum', 'line', [lineDataset('Humidity %', field(pts, 'humidity'), c.cool), @@ -546,7 +610,7 @@ async function loadHistory(range) { if (r.status === 401) { location.href = '/dashboard/login'; return; } if (!r.ok) throw new Error('Failed'); const data = await r.json(); - renderCharts(data, range); + renderCharts(data, range, yoyEnabled ? yoyData : null); const line = document.getElementById('updatedLine'); if (line) { const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); @@ -558,11 +622,13 @@ async function loadHistory(range) { } // ── Init ────────────────────────────────────────────────────────────────────── -document.querySelectorAll('.range-btn').forEach(btn => { +document.querySelectorAll('.range-btn[data-range]').forEach(btn => { btn.addEventListener('click', () => { - document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.range-btn[data-range]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentRange = btn.dataset.range; + yoyData = null; yoyEnabled = false; yoyBtn.classList.remove('active'); + updateYoyVisibility(currentRange); loadHistory(currentRange); }); }); @@ -676,9 +742,144 @@ async function pollBackfill() { document.getElementById('backfillBtn').addEventListener('click', startBackfill); +// ── Pressure trend ──────────────────────────────────────────────────────────── +async function updatePressureTrend() { + try { + const r = await fetch('/api/station/history?range=6h'); + if (!r.ok) return; + const data = await r.json(); + if (data.length < 2) return; + const recent = data[data.length - 1]?.baromrelin; + const older = data[Math.max(0, data.length - 19)]?.baromrelin; // ~3h ago (at 1/min, 18 readings = ~18min... adjust for actual density) + const threeHoursAgo = Date.now() - 3 * 3600000; + const baseline = data.find(r => r.dateutc >= threeHoursAgo)?.baromrelin; + if (recent == null || baseline == null) return; + const diff = recent - baseline; + const el = document.getElementById('sTrend'); + if (!el) return; + if (diff > 0.02) { el.textContent = '↑'; el.title = `Rising +${diff.toFixed(3)} inHg in 3h`; el.style.color = 'var(--cool)'; } + else if (diff < -0.02) { el.textContent = '↓'; el.title = `Falling ${diff.toFixed(3)} inHg in 3h`; el.style.color = 'var(--accent)'; } + else { el.textContent = '→'; el.title = `Steady (${diff > 0 ? '+' : ''}${diff.toFixed(3)} inHg in 3h)`; el.style.color = 'var(--ink-light)'; } + } catch {} +} + +// ── Rain totals + degree days ───────────────────────────────────────────────── +async function updateExtStats() { + try { + const [rt, dd, an] = await Promise.all([ + fetch('/api/station/rain-totals').then(r => r.ok ? r.json() : null), + fetch('/api/station/degree-days?rangeMs=' + (30 * 86400000)).then(r => r.ok ? r.json() : null), + fetch('/api/station/anomaly').then(r => r.ok ? r.json() : null), + ]); + if (rt) { + const s = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v != null ? v : '—'; }; + s('sRain7d', rt.rain7d); + s('sRain30d', rt.rain30d); + s('sRainYTD', rt.rainYTD); + } + if (dd) { + document.getElementById('sHDD').textContent = dd.hdd; + document.getElementById('sCDD').textContent = dd.cdd; + } + if (an?.available) { + const tile = document.getElementById('sAnomalyTile'); + tile.style.display = ''; + const diff = an.currentTemp != null && an.mean != null ? (an.currentTemp - an.mean) : null; + document.getElementById('sAnomaly').textContent = an.anomaly === 'above' ? 'Above Normal' : an.anomaly === 'below' ? 'Below Normal' : 'Near Normal'; + document.getElementById('sAnomalyNote').textContent = diff != null ? `${diff > 0 ? '+' : ''}${diff.toFixed(1)}°F vs avg` : ''; + tile.className = 'stat' + (an.anomaly === 'above' ? ' anomaly-above' : an.anomaly === 'below' ? ' anomaly-below' : ''); + } + } catch {} +} + +// ── Records section ─────────────────────────────────────────────────────────── +function fmtDate(ts) { + if (!ts) return '—'; + return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +async function loadRecords() { + const panel = document.getElementById('recordsPanel'); + if (!panel) return; + panel.addEventListener('toggle', async () => { + if (!panel.open) return; + const body = document.getElementById('recordsBody'); + if (body.querySelector('.records-grid')) return; // already loaded + try { + const r = await fetch('/api/station/records'); + if (!r.ok) { body.innerHTML = '
No records yet.
'; return; } + const d = await r.json(); + if (!d) { body.innerHTML = '
No data recorded yet.
'; return; } + const since = d.firstReading ? `${d.totalReadings.toLocaleString()} readings since ${fmtDate(d.firstReading)}` : ''; + body.innerHTML = ` +

${since}

+
+
Hottest
${d.highTemp != null ? d.highTemp.toFixed(1) + '°F' : '—'}
${fmtDate(d.highTempDate)}
+
Coldest
${d.lowTemp != null ? d.lowTemp.toFixed(1) + '°F' : '—'}
${fmtDate(d.lowTempDate)}
+
Highest Gust
${d.highGust != null ? d.highGust.toFixed(1) + ' mph' : '—'}
${fmtDate(d.highGustDate)}
+
Highest Rain Rate
${d.highRainRate != null ? d.highRainRate.toFixed(2) + ' in/hr' : '—'}
${fmtDate(d.highRainRateDate)}
+
Wettest Day
${d.highDailyRain != null ? d.highDailyRain.toFixed(2) + '"' : '—'}
${fmtDate(d.highDailyRainDate)}
+
Highest UV
${d.highUV != null ? d.highUV.toFixed(0) : '—'}
${fmtDate(d.highUVDate)}
+
Peak Solar
${d.highSolar != null ? d.highSolar.toFixed(0) + ' W/m²' : '—'}
${fmtDate(d.highSolarDate)}
+
Highest Pressure
${d.highPressure != null ? d.highPressure.toFixed(2) + ' inHg' : '—'}
${fmtDate(d.highPressureDate)}
+
Lowest Pressure
${d.lowPressure != null ? d.lowPressure.toFixed(2) + ' inHg' : '—'}
${fmtDate(d.lowPressureDate)}
+
Highest Humidity
${d.highHumidity != null ? d.highHumidity + '%' : '—'}
${fmtDate(d.highHumidityDate)}
+
Lowest Humidity
${d.lowHumidity != null ? d.lowHumidity + '%' : '—'}
${fmtDate(d.lowHumidityDate)}
+
`; + } catch { document.getElementById('recordsBody').innerHTML = '
Failed to load records.
'; } + }, { once: false }); +} + +// ── Year-over-year ──────────────────────────────────────────────────────────── +let yoyEnabled = false; +let yoyData = null; + +const yoyBtn = document.getElementById('yoyBtn'); +yoyBtn.addEventListener('click', async () => { + yoyEnabled = !yoyEnabled; + yoyBtn.classList.toggle('active', yoyEnabled); + if (yoyEnabled && !yoyData) { + yoyBtn.textContent = '…'; + try { + const r = await fetch(`/api/station/yoy?range=${currentRange}`); + yoyData = r.ok ? await r.json() : null; + } catch {} + yoyBtn.textContent = 'YoY'; + } + loadHistory(currentRange); +}); + +// Show YoY button only for long ranges +function updateYoyVisibility(range) { + const longRange = ['1y','2y','3y'].includes(range); + yoyBtn.style.display = longRange ? '' : 'none'; + if (!longRange) { yoyEnabled = false; yoyData = null; yoyBtn.classList.remove('active'); } +} + +// Patch loadHistory to pass yoyData to renderCharts +const _origLoadHistory = loadHistory; +// Override renderCharts to accept yoy +const _origRenderCharts = renderCharts; + +// ── CSV Export ──────────────────────────────────────────────────────────────── +function updateExportLink() { + const sel = document.getElementById('exportRange'); + const btn = document.getElementById('exportBtn'); + if (sel && btn) btn.href = `/api/station/export.csv?range=${sel.value}`; +} +document.getElementById('exportRange')?.addEventListener('change', updateExportLink); +updateExportLink(); + +// ── Init all new features ───────────────────────────────────────────────────── updateLatest(); +updatePressureTrend(); +updateExtStats(); +loadRecords(); loadHistory(currentRange); checkKeyStatus(); + +setInterval(updatePressureTrend, 120000); +setInterval(updateExtStats, 300000); diff --git a/app/server.js b/app/server.js index e33681e..0af9794 100644 --- a/app/server.js +++ b/app/server.js @@ -782,6 +782,14 @@ async function alertWatcherCron() { await doNtfy(sub.ntfyTopic, '😷 Air Quality Alert', body); } } + // ── Rain rate (station only) ───────────────────────────────────────── + if (sub.thresholds.rainRate && stationObs?.rainratein != null && stationObs.rainratein > sub.thresholds.rainRate && !alerted.rainRate) { + alerted.rainRate = true; + const body = `Rain rate at ${stationObs.rainratein.toFixed(2)} in/hr — your threshold is ${sub.thresholds.rainRate} in/hr`; + await doWebPush(sub, { title: '🌧 Heavy Rain Alert', body, tag: 'rain-threshold', url: '/' }); + await doNtfy(sub.ntfyTopic, '🌧 Heavy Rain Alert', body, 'high'); + } + sub.lastThresholdAlerted = alerted; saveSubs(); } @@ -976,4 +984,164 @@ app.get('/api/station/backfill/status', requireDashboardAuth, (_req, res) => { res.json(backfillState); }); +// ── Station analytics ───────────────────────────────────────────────────────── + +// Records — scan all JSONL, cache 1h +let recordsCache = null; +let recordsCacheAt = 0; + +function computeStationRecords() { + let files; + try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return null; } + if (!files.length) return null; + const rec = { + highTemp: null, highTempDate: null, lowTemp: null, lowTempDate: null, + highGust: null, highGustDate: null, + highRainRate: null, highRainRateDate: null, highDailyRain: null, highDailyRainDate: null, + highUV: null, highUVDate: null, highSolar: null, highSolarDate: null, + highPressure: null, highPressureDate: null, lowPressure: null, lowPressureDate: null, + highHumidity: null, highHumidityDate: null, lowHumidity: null, lowHumidityDate: null, + totalReadings: 0, firstReading: null, lastReading: null, + }; + for (const file of files) { + try { + const lines = fs.readFileSync(path.join(STATION_DIR, file), 'utf8').split('\n').filter(Boolean); + for (const line of lines) { + try { + const r = JSON.parse(line); + if (!r.dateutc) continue; + rec.totalReadings++; + if (!rec.firstReading || r.dateutc < rec.firstReading) rec.firstReading = r.dateutc; + if (!rec.lastReading || r.dateutc > rec.lastReading) rec.lastReading = r.dateutc; + if (r.tempf != null) { + if (rec.highTemp == null || r.tempf > rec.highTemp) { rec.highTemp = r.tempf; rec.highTempDate = r.dateutc; } + if (rec.lowTemp == null || r.tempf < rec.lowTemp) { rec.lowTemp = r.tempf; rec.lowTempDate = r.dateutc; } + } + if (r.windgustmph != null && (rec.highGust == null || r.windgustmph > rec.highGust)) { rec.highGust = r.windgustmph; rec.highGustDate = r.dateutc; } + if (r.rainratein != null && (rec.highRainRate == null || r.rainratein > rec.highRainRate)) { rec.highRainRate = r.rainratein; rec.highRainRateDate = r.dateutc; } + if (r.dailyrainin != null && (rec.highDailyRain == null || r.dailyrainin > rec.highDailyRain)) { rec.highDailyRain = r.dailyrainin; rec.highDailyRainDate = r.dateutc; } + if (r.uv != null && (rec.highUV == null || r.uv > rec.highUV)) { rec.highUV = r.uv; rec.highUVDate = r.dateutc; } + if (r.solarradiation != null && (rec.highSolar == null || r.solarradiation > rec.highSolar)) { rec.highSolar = r.solarradiation; rec.highSolarDate = r.dateutc; } + if (r.baromrelin != null) { + if (rec.highPressure == null || r.baromrelin > rec.highPressure) { rec.highPressure = r.baromrelin; rec.highPressureDate = r.dateutc; } + if (rec.lowPressure == null || r.baromrelin < rec.lowPressure) { rec.lowPressure = r.baromrelin; rec.lowPressureDate = r.dateutc; } + } + if (r.humidity != null) { + if (rec.highHumidity == null || r.humidity > rec.highHumidity) { rec.highHumidity = r.humidity; rec.highHumidityDate = r.dateutc; } + if (rec.lowHumidity == null || r.humidity < rec.lowHumidity) { rec.lowHumidity = r.humidity; rec.lowHumidityDate = r.dateutc; } + } + } catch {} + } + } catch {} + } + return rec; +} + +app.get('/api/station/records', requireDashboardAuth, (_req, res) => { + if (recordsCache && Date.now() - recordsCacheAt < 3600000) return res.json(recordsCache); + recordsCache = computeStationRecords(); + recordsCacheAt = Date.now(); + res.json(recordsCache); +}); + +// Rain totals — max dailyrainin per calendar day, summed +app.get('/api/station/rain-totals', requireDashboardAuth, (_req, res) => { + const now = Date.now(); + const ytdCutoff = new Date(new Date().getFullYear(), 0, 1).getTime(); + function sumDailyRain(readings) { + const byDay = {}; + for (const r of readings) { + if (r.dailyrainin == null || !r.dateutc) continue; + const d = stationDateKey(r.dateutc); + if (byDay[d] == null || r.dailyrainin > byDay[d]) byDay[d] = r.dailyrainin; + } + return +Object.values(byDay).reduce((a, b) => a + b, 0).toFixed(2); + } + res.json({ + rain7d: sumDailyRain(loadStationReadings(7 * 86400000)), + rain30d: sumDailyRain(loadStationReadings(30 * 86400000)), + rainYTD: sumDailyRain(loadStationReadings(now - ytdCutoff)), + }); +}); + +// Heating/cooling degree days +app.get('/api/station/degree-days', requireDashboardAuth, (req, res) => { + const base = parseFloat(req.query.base || '65'); + const rangeMs = Math.min(parseInt(req.query.rangeMs || String(30 * 86400000)), 365 * 86400000); + const readings = loadStationReadings(rangeMs); + const byDay = {}; + for (const r of readings) { + if (r.tempf == null || !r.dateutc) continue; + const d = stationDateKey(r.dateutc); + if (!byDay[d]) byDay[d] = []; + byDay[d].push(r.tempf); + } + let hdd = 0, cdd = 0; + for (const temps of Object.values(byDay)) { + const avg = temps.reduce((a, b) => a + b, 0) / temps.length; + hdd += Math.max(0, base - avg); + cdd += Math.max(0, avg - base); + } + res.json({ hdd: +hdd.toFixed(1), cdd: +cdd.toFixed(1), base, days: Object.keys(byDay).length }); +}); + +// CSV export +const RANGE_MS_MAP = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 }; +app.get('/api/station/export.csv', requireDashboardAuth, (req, res) => { + const range = req.query.range || '30d'; + const readings = loadStationReadings(RANGE_MS_MAP[range] || RANGE_MS_MAP['30d']); + const cols = ['date','tempf','feelsLike','humidity','dewPoint','windspeedmph','windgustmph','winddir','baromrelin','baromabsin','rainratein','dailyrainin','weeklyrainin','monthlyrainin','uv','solarradiation','battout']; + const header = cols.join(','); + const rows = readings.map(r => cols.map(c => { + if (c === 'date') return new Date(r.dateutc).toISOString(); + return r[c] == null ? '' : r[c]; + }).join(',')); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="station-${range}-${new Date().toISOString().slice(0,10)}.csv"`); + res.send([header, ...rows].join('\n')); +}); + +// Year-over-year: current range + same range 1 year prior (timestamps shifted forward 1yr for chart overlay) +app.get('/api/station/yoy', requireDashboardAuth, (req, res) => { + const rangeMs = RANGE_MS_MAP[req.query.range] || RANGE_MS_MAP['1y']; + const YEAR_MS = 365.25 * 86400000; + const current = loadStationReadings(rangeMs); + const priorEnd = Date.now() - YEAR_MS; + const prior = loadStationReadings(rangeMs + YEAR_MS).filter(r => r.dateutc >= priorEnd - rangeMs && r.dateutc <= priorEnd); + const priorShifted = prior.map(r => ({ dateutc: Math.round(r.dateutc + YEAR_MS), tempf: r.tempf, humidity: r.humidity, windspeedmph: r.windspeedmph, baromrelin: r.baromrelin })); + res.json({ current: current.map(r => ({ dateutc: r.dateutc, tempf: r.tempf, humidity: r.humidity, windspeedmph: r.windspeedmph, baromrelin: r.baromrelin })), prior: priorShifted }); +}); + +// Anomaly — hourly baseline from all historical data +app.get('/api/station/anomaly', requireDashboardAuth, (_req, res) => { + const all = loadStationReadings(365 * 86400000); + if (all.length < 200) return res.json({ available: false }); + const byHour = Array.from({ length: 24 }, () => []); + for (const r of all) { + if (r.tempf == null) continue; + byHour[new Date(r.dateutc).getHours()].push(r.tempf); + } + const hourStats = byHour.map(temps => { + if (temps.length < 10) return null; + const mean = temps.reduce((a, b) => a + b) / temps.length; + const stddev = Math.sqrt(temps.reduce((a, b) => a + (b - mean) ** 2, 0) / temps.length); + return { mean: +mean.toFixed(1), stddev: +stddev.toFixed(1) }; + }); + const last = all[all.length - 1]; + const h = new Date(last.dateutc).getHours(); + const stat = hourStats[h]; + const z = (stat && stat.stddev > 0) ? (last.tempf - stat.mean) / stat.stddev : 0; + res.json({ available: true, hourStats, anomaly: z > 2 ? 'above' : z < -2 ? 'below' : 'normal', currentTemp: last.tempf, currentHour: h, mean: stat?.mean, stddev: stat?.stddev }); +}); + +// Public conditions page + API (no auth required) +app.get('/conditions', (_req, res) => { + res.sendFile(path.join(__dirname, 'public', 'conditions.html')); +}); +app.get('/api/conditions/public', (_req, res) => { + if (!ambientServerConfig.apiKey) return res.status(503).json({ error: 'Not configured' }); + const cached = ambientCaches.get(ambientServerConfig.apiKey); + res.json(cached?.data?.[0]?.lastData || null); +}); + app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`));