Add station alerts, dashboard analytics, and settings improvements
Alerts (server): - Station temp high/low threshold alerts via push + ntfy - Freeze warning at ≤34°F (station reading) - Station wind gust threshold alert from real readings - Station offline alert when no data for >20 min (auto-clears on reconnect) Dashboard: - Trend arrows (▲/▼/–) on temp, humidity, dew, wind, pressure, UV, solar tiles comparing current vs 1h ago via new /api/station/trend endpoint - Offline banner when station last reported >15 min ago - Monthly summary table (avg temp, high temp, max gust, total rain, readings) - Temperature calendar heatmap — GitHub-style year grid, blue→orange scale - Alert threshold settings UI: temp high/low, wind gust, rain rate saved to all push subscriptions via new /api/subscriptions/thresholds - Station name setting — persisted in auth file, served on conditions page - Change password form in settings (no recovery flow needed) Server: - GET/POST /api/dashboard/station-name - POST /api/dashboard/change-password - GET /api/station/monthly-summary - GET /api/station/trend - GET /api/subscriptions, POST /api/subscriptions/thresholds - /api/conditions/public includes stationName field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
beb627830f
commit
e851fd1ca0
3 files changed
+435
-11
No files matched your search
@@ -609,6 +609,12 @@ async function load() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Station name (server may include it)
|
||||
if (d.stationName) {
|
||||
const nameEl = $('stationName');
|
||||
if (nameEl) nameEl.textContent = d.stationName;
|
||||
}
|
||||
|
||||
// Header
|
||||
if (d.dateutc) {
|
||||
const dt = new Date(d.dateutc);
|
||||
|
||||
+283
-7
@@ -110,6 +110,31 @@ a { color: var(--cool); text-decoration: none; }
|
||||
.progress-bar { height: 100%; background: var(--cool); border-radius: 3px; transition: width 0.5s; width: 0%; }
|
||||
.progress-text { font-size: 0.82rem; color: var(--ink-light); margin-top: 0.4rem; }
|
||||
.backfill-progress { margin-top: 0.75rem; }
|
||||
/* Trend arrows */
|
||||
.stat-trend { font-size: 0.85rem; margin-left: 0.25rem; }
|
||||
.trend-up { color: #e07848; }
|
||||
.trend-down { color: #5ab0d8; }
|
||||
.trend-flat { color: var(--ink-light); opacity: 0.5; }
|
||||
/* Offline banner */
|
||||
.offline-banner { background: rgba(194,90,42,0.12); border: 1px solid var(--accent); border-radius: 10px; padding: 0.6rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; color: var(--accent); display: none; }
|
||||
/* Monthly summary */
|
||||
.monthly-table-wrap { overflow-x: auto; margin-top: 0.75rem; }
|
||||
.monthly-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.monthly-table th { text-align: left; padding: 0.4rem 0.75rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); border-bottom: 1px solid var(--border); }
|
||||
.monthly-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
|
||||
.monthly-table tr:last-child td { border-bottom: none; }
|
||||
.monthly-table tr:hover td { background: var(--paper-darker); }
|
||||
/* Heatmap */
|
||||
.heatmap-wrap { overflow-x: auto; padding-bottom: 0.5rem; }
|
||||
.heatmap { display: inline-grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); gap: 3px; }
|
||||
.heatmap-cell { width: 14px; height: 14px; border-radius: 3px; background: var(--border); position: relative; }
|
||||
.heatmap-cell[title]:hover::after { content: attr(title); position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); color: #fff; padding: 3px 7px; border-radius: 5px; font-size: 11px; white-space: nowrap; pointer-events: none; z-index: 10; }
|
||||
.heatmap-legend { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.6rem; font-size: 0.72rem; color: var(--ink-light); }
|
||||
.heatmap-legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
|
||||
/* Settings sections */
|
||||
.settings-section-hdr { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-light); margin: 1.25rem 0 0.6rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--border); }
|
||||
.threshold-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
@media (max-width: 480px) { .threshold-row { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -127,23 +152,25 @@ a { color: var(--cool); text-decoration: none; }
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="offline-banner" id="offlineBanner"></div>
|
||||
|
||||
<!-- Current stats -->
|
||||
<div class="stats" id="statsRow">
|
||||
<div class="stat"><div class="stat-label">Temperature</div><div class="stat-val" id="sTempF">—</div><div class="stat-unit">°F</div></div>
|
||||
<div class="stat"><div class="stat-label">Temperature</div><div class="stat-val" id="sTempF">—<span class="stat-trend" id="trTempF"></span></div><div class="stat-unit">°F</div></div>
|
||||
<div class="stat"><div class="stat-label">Feels Like</div><div class="stat-val" id="sFeels">—</div><div class="stat-unit">°F</div></div>
|
||||
<div class="stat"><div class="stat-label">Humidity</div><div class="stat-val" id="sHum">—</div><div class="stat-unit">%</div></div>
|
||||
<div class="stat"><div class="stat-label">Dew Point</div><div class="stat-val" id="sDew">—</div><div class="stat-unit">°F</div></div>
|
||||
<div class="stat"><div class="stat-label">Wind</div><div class="stat-val" id="sWind">—</div><div class="stat-unit">mph</div></div>
|
||||
<div class="stat"><div class="stat-label">Humidity</div><div class="stat-val" id="sHum">—<span class="stat-trend" id="trHum"></span></div><div class="stat-unit">%</div></div>
|
||||
<div class="stat"><div class="stat-label">Dew Point</div><div class="stat-val" id="sDew">—<span class="stat-trend" id="trDew"></span></div><div class="stat-unit">°F</div></div>
|
||||
<div class="stat"><div class="stat-label">Wind</div><div class="stat-val" id="sWind">—<span class="stat-trend" id="trWind"></span></div><div class="stat-unit">mph</div></div>
|
||||
<div class="stat"><div class="stat-label">Wind Gust</div><div class="stat-val" id="sGust">—</div><div class="stat-unit">mph</div></div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Pressure</div>
|
||||
<div class="stat-val" id="sPres">—</div>
|
||||
<div class="stat-val" id="sPres">—<span class="stat-trend" id="trPres"></span></div>
|
||||
<div class="stat-unit">inHg <span id="sTrend" style="font-size:1rem;"></span></div>
|
||||
</div>
|
||||
<div class="stat"><div class="stat-label">Rain Today</div><div class="stat-val" id="sRain">—</div><div class="stat-unit">in</div></div>
|
||||
<div class="stat"><div class="stat-label">Rain Rate</div><div class="stat-val" id="sRainRate">—</div><div class="stat-unit">in/hr</div></div>
|
||||
<div class="stat"><div class="stat-label">UV Index</div><div class="stat-val" id="sUV">—</div><div class="stat-unit"></div></div>
|
||||
<div class="stat"><div class="stat-label">Solar Rad.</div><div class="stat-val" id="sSolar">—</div><div class="stat-unit">W/m²</div></div>
|
||||
<div class="stat"><div class="stat-label">UV Index</div><div class="stat-val" id="sUV">—<span class="stat-trend" id="trUV"></span></div><div class="stat-unit"></div></div>
|
||||
<div class="stat"><div class="stat-label">Solar Rad.</div><div class="stat-val" id="sSolar">—<span class="stat-trend" id="trSolar"></span></div><div class="stat-unit">W/m²</div></div>
|
||||
<div class="stat"><div class="stat-label">Battery</div><div class="stat-val" id="sBatt">—</div><div class="stat-unit"></div></div>
|
||||
</div>
|
||||
|
||||
@@ -191,6 +218,22 @@ a { color: var(--cool); text-decoration: none; }
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Monthly Summary -->
|
||||
<details class="settings-panel" id="monthlyPanel" style="margin-top:1rem">
|
||||
<summary>Monthly Summary</summary>
|
||||
<div class="settings-body" id="monthlyBody">
|
||||
<div class="status">Loading…</div>
|
||||
</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>
|
||||
|
||||
<!-- Settings -->
|
||||
<details class="settings-panel" id="settingsPanel">
|
||||
<summary>Station API Keys</summary>
|
||||
@@ -256,6 +299,45 @@ a { color: var(--cool); text-decoration: none; }
|
||||
<div class="progress-text" id="progressText"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="settings-divider">
|
||||
|
||||
<div class="settings-section-hdr">Alert Thresholds (Station)</div>
|
||||
<p class="keys-hint" style="margin:0 0 0.75rem">Leave blank to disable. These extend your existing push/ntfy alert subscriptions.</p>
|
||||
<div class="threshold-row">
|
||||
<div><label>High Temp (°F)</label><input id="thrTempHigh" type="number" placeholder="e.g. 95" autocomplete="off"></div>
|
||||
<div><label>Low Temp (°F)</label><input id="thrTempLow" type="number" placeholder="e.g. 35" autocomplete="off"></div>
|
||||
<div><label>Wind Gust (mph)</label><input id="thrWind" type="number" placeholder="e.g. 40" autocomplete="off"></div>
|
||||
<div><label>Rain Rate (in/hr)</label><input id="thrRainRate" type="number" step="0.01" placeholder="e.g. 0.5" autocomplete="off"></div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button id="saveThreshBtn">Save Thresholds</button>
|
||||
<span id="threshMsg" class="save-msg"></span>
|
||||
</div>
|
||||
|
||||
<hr class="settings-divider">
|
||||
|
||||
<div class="settings-section-hdr">Station Display</div>
|
||||
<div style="margin-bottom:0.75rem">
|
||||
<label>Station Name (shown on public conditions page)</label>
|
||||
<input id="inputStationName" type="text" placeholder="e.g. Genesis 8:22" maxlength="60" autocomplete="off">
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button id="saveNameBtn">Save Name</button>
|
||||
<span id="nameMsg" class="save-msg"></span>
|
||||
</div>
|
||||
|
||||
<hr class="settings-divider">
|
||||
|
||||
<div class="settings-section-hdr">Change Password</div>
|
||||
<div class="settings-grid">
|
||||
<div><label>Current Password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
|
||||
<div><label>New Password (min 6 chars)</label><input id="pwNew" type="password" autocomplete="new-password"></div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button id="changePwBtn">Change Password</button>
|
||||
<span id="pwMsg" class="save-msg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -599,6 +681,7 @@ async function updateLatest() {
|
||||
if (meta && d.dateutc) {
|
||||
meta.textContent = 'Updated ' + new Date(d.dateutc).toLocaleTimeString();
|
||||
}
|
||||
updateOfflineBanner(d.dateutc || null);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -871,16 +954,209 @@ function updateExportLink() {
|
||||
document.getElementById('exportRange')?.addEventListener('change', updateExportLink);
|
||||
updateExportLink();
|
||||
|
||||
// ── Trend arrows ─────────────────────────────────────────────────────────────
|
||||
async function updateTrends() {
|
||||
try {
|
||||
const r = await fetch('/api/station/trend');
|
||||
if (!r.ok) return;
|
||||
const t = await r.json();
|
||||
const arrows = { up: '▲', down: '▼', flat: '–' };
|
||||
const cls = { up: 'trend-up', down: 'trend-down', flat: 'trend-flat' };
|
||||
const map = { tempf: 'trTempF', humidity: 'trHum', dewPoint: 'trDew', windspeedmph: 'trWind', baromrelin: 'trPres', uv: 'trUV', solarradiation: 'trSolar' };
|
||||
for (const [key, elId] of Object.entries(map)) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!el || !t[key]) continue;
|
||||
el.textContent = arrows[t[key]] || '';
|
||||
el.className = 'stat-trend ' + (cls[t[key]] || '');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Offline banner ────────────────────────────────────────────────────────────
|
||||
function updateOfflineBanner(lastDateutc) {
|
||||
const banner = document.getElementById('offlineBanner');
|
||||
if (!banner) return;
|
||||
if (!lastDateutc) { banner.style.display = 'none'; return; }
|
||||
const mins = Math.round((Date.now() - lastDateutc) / 60000);
|
||||
if (mins > 15) {
|
||||
banner.textContent = `⚠ Station last reported ${mins} minutes ago — may be offline`;
|
||||
banner.style.display = 'block';
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monthly summary ───────────────────────────────────────────────────────────
|
||||
const monthlyPanel = document.getElementById('monthlyPanel');
|
||||
if (monthlyPanel) {
|
||||
monthlyPanel.addEventListener('toggle', async () => {
|
||||
if (!monthlyPanel.open) return;
|
||||
const body = document.getElementById('monthlyBody');
|
||||
if (body.querySelector('table')) return;
|
||||
try {
|
||||
const r = await fetch('/api/station/monthly-summary');
|
||||
if (!r.ok) throw new Error();
|
||||
const data = await r.json();
|
||||
if (!data.length) { body.innerHTML = '<div class="status">No monthly data yet.</div>'; return; }
|
||||
const rows = data.slice().reverse().map(m => {
|
||||
const [yr, mo] = m.month.split('-');
|
||||
const label = new Date(+yr, +mo - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
return `<tr>
|
||||
<td>${label}</td>
|
||||
<td>${m.avgTemp != null ? m.avgTemp.toFixed(1) + '°F' : '—'}</td>
|
||||
<td>${m.highTemp != null ? m.highTemp.toFixed(1) + '°F' : '—'}</td>
|
||||
<td>${m.highGust != null ? m.highGust + ' mph' : '—'}</td>
|
||||
<td>${m.totalRain != null ? m.totalRain.toFixed(2) + '"' : '—'}</td>
|
||||
<td style="color:var(--ink-light)">${m.readings.toLocaleString()}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
body.innerHTML = `<div class="monthly-table-wrap"><table class="monthly-table">
|
||||
<thead><tr><th>Month</th><th>Avg Temp</th><th>High Temp</th><th>Max Gust</th><th>Total Rain</th><th>Readings</th></tr></thead>
|
||||
<tbody>${rows}</tbody></table></div>`;
|
||||
} catch { document.getElementById('monthlyBody').innerHTML = '<div class="status error">Failed to load.</div>'; }
|
||||
}, { 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 });
|
||||
}
|
||||
|
||||
// ── Station name ──────────────────────────────────────────────────────────────
|
||||
async function loadStationName() {
|
||||
try {
|
||||
const r = await fetch('/api/dashboard/station-name');
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
const el = document.getElementById('inputStationName');
|
||||
if (el && d.name) el.value = d.name;
|
||||
} catch {}
|
||||
}
|
||||
document.getElementById('saveNameBtn')?.addEventListener('click', async () => {
|
||||
const name = document.getElementById('inputStationName')?.value?.trim();
|
||||
const msg = document.getElementById('nameMsg');
|
||||
if (!name) { msg.textContent = 'Enter a name'; msg.className = 'save-msg err'; return; }
|
||||
try {
|
||||
const r = await fetch('/api/dashboard/station-name', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||
if (r.ok) { msg.textContent = 'Saved'; msg.className = 'save-msg'; }
|
||||
else { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
|
||||
setTimeout(() => msg.textContent = '', 3000);
|
||||
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
|
||||
});
|
||||
|
||||
// ── Change password ───────────────────────────────────────────────────────────
|
||||
document.getElementById('changePwBtn')?.addEventListener('click', async () => {
|
||||
const cur = document.getElementById('pwCurrent')?.value;
|
||||
const nw = document.getElementById('pwNew')?.value;
|
||||
const msg = document.getElementById('pwMsg');
|
||||
if (!cur || !nw || nw.length < 6) { msg.textContent = 'Fill both fields (min 6 chars)'; msg.className = 'save-msg err'; return; }
|
||||
try {
|
||||
const r = await fetch('/api/dashboard/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: cur, newPassword: nw }) });
|
||||
if (r.ok) { msg.textContent = 'Password changed'; msg.className = 'save-msg'; document.getElementById('pwCurrent').value = ''; document.getElementById('pwNew').value = ''; }
|
||||
else { const e = await r.json(); msg.textContent = e.error || 'Error'; msg.className = 'save-msg err'; }
|
||||
setTimeout(() => msg.textContent = '', 4000);
|
||||
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
|
||||
});
|
||||
|
||||
// ── Alert thresholds ──────────────────────────────────────────────────────────
|
||||
async function loadThresholds() {
|
||||
try {
|
||||
const r = await fetch('/api/subscriptions');
|
||||
if (!r.ok) return;
|
||||
const subs = await r.json();
|
||||
const sub = subs?.find(s => s.thresholds);
|
||||
if (!sub) return;
|
||||
const t = sub.thresholds;
|
||||
const set = (id, v) => { const el = document.getElementById(id); if (el && v) el.value = v; };
|
||||
set('thrTempHigh', t.tempHigh);
|
||||
set('thrTempLow', t.tempLow);
|
||||
set('thrWind', t.wind);
|
||||
set('thrRainRate', t.rainRate);
|
||||
} catch {}
|
||||
}
|
||||
document.getElementById('saveThreshBtn')?.addEventListener('click', async () => {
|
||||
const msg = document.getElementById('threshMsg');
|
||||
const val = id => { const v = document.getElementById(id)?.value; return v ? parseFloat(v) : undefined; };
|
||||
const thresholds = {};
|
||||
const th = val('thrTempHigh'); if (th) thresholds.tempHigh = th;
|
||||
const tl = val('thrTempLow'); if (tl) thresholds.tempLow = tl;
|
||||
const wi = val('thrWind'); if (wi) thresholds.wind = wi;
|
||||
const rr = val('thrRainRate'); if (rr) thresholds.rainRate = rr;
|
||||
try {
|
||||
const r = await fetch('/api/subscriptions/thresholds', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thresholds }) });
|
||||
if (r.ok) { msg.textContent = 'Saved — applies to all your subscriptions'; msg.className = 'save-msg'; }
|
||||
else { msg.textContent = 'Error saving'; msg.className = 'save-msg err'; }
|
||||
setTimeout(() => msg.textContent = '', 4000);
|
||||
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
|
||||
});
|
||||
|
||||
// ── Init all new features ─────────────────────────────────────────────────────
|
||||
updateLatest();
|
||||
updatePressureTrend();
|
||||
updateExtStats();
|
||||
updateTrends();
|
||||
loadRecords();
|
||||
loadHistory(currentRange);
|
||||
checkKeyStatus();
|
||||
loadStationName();
|
||||
loadThresholds();
|
||||
|
||||
setInterval(updatePressureTrend, 120000);
|
||||
setInterval(updateExtStats, 300000);
|
||||
setInterval(updateTrends, 120000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+146
-4
@@ -787,13 +787,52 @@ 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) {
|
||||
// ── Station-based threshold alerts ───────────────────────────────────
|
||||
if (stationObs) {
|
||||
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`;
|
||||
const body = `Rain rate at ${stationObs.rainratein.toFixed(2)} in/hr — 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');
|
||||
}
|
||||
if (sub.thresholds.tempHigh && stationObs.tempf != null && stationObs.tempf > sub.thresholds.tempHigh && !alerted.tempHigh) {
|
||||
alerted.tempHigh = true;
|
||||
const body = `Station temp at ${stationObs.tempf.toFixed(1)}°F — threshold is ${sub.thresholds.tempHigh}°F`;
|
||||
await doWebPush(sub, { title: '🌡 High Temp Alert', body, tag: 'temp-high', url: '/' });
|
||||
await doNtfy(sub.ntfyTopic, '🌡 High Temp Alert', body, 'high');
|
||||
}
|
||||
if (sub.thresholds.tempLow && stationObs.tempf != null && stationObs.tempf < sub.thresholds.tempLow && !alerted.stationTempLow) {
|
||||
alerted.stationTempLow = true;
|
||||
const body = `Station temp at ${stationObs.tempf.toFixed(1)}°F — threshold is ${sub.thresholds.tempLow}°F`;
|
||||
await doWebPush(sub, { title: '🧊 Low Temp Alert', body, tag: 'temp-low', url: '/' });
|
||||
await doNtfy(sub.ntfyTopic, '🧊 Low Temp Alert', body, 'default');
|
||||
}
|
||||
if (stationObs.tempf != null && stationObs.tempf <= 34 && !alerted.freeze) {
|
||||
alerted.freeze = true;
|
||||
const body = `Station reading ${stationObs.tempf.toFixed(1)}°F — freezing conditions`;
|
||||
await doWebPush(sub, { title: '❄️ Freeze Warning', body, tag: 'freeze', url: '/' });
|
||||
await doNtfy(sub.ntfyTopic, '❄️ Freeze Warning', body, 'urgent');
|
||||
}
|
||||
if (sub.thresholds.wind && stationObs.windgustmph != null && stationObs.windgustmph > sub.thresholds.wind && !alerted.stationWind) {
|
||||
alerted.stationWind = true;
|
||||
const body = `Station gust at ${stationObs.windgustmph.toFixed(0)} mph — threshold is ${sub.thresholds.wind} mph`;
|
||||
await doWebPush(sub, { title: '💨 Wind Gust Alert', body, tag: 'wind-station', url: '/' });
|
||||
await doNtfy(sub.ntfyTopic, '💨 Wind Gust Alert', body, 'high');
|
||||
}
|
||||
}
|
||||
// ── Station offline alert ────────────────────────────────────────────
|
||||
if (!stationObs && ambientServerConfig.apiKey) {
|
||||
const lastReading = lastRecordedDateutc;
|
||||
if (lastReading && Date.now() - lastReading > 20 * 60 * 1000 && !alerted.stationOffline) {
|
||||
alerted.stationOffline = true;
|
||||
const mins = Math.round((Date.now() - lastReading) / 60000);
|
||||
const body = `No station data for ${mins} minutes`;
|
||||
await doWebPush(sub, { title: '📡 Station Offline', body, tag: 'station-offline', url: '/' });
|
||||
await doNtfy(sub.ntfyTopic, '📡 Station Offline', body, 'default');
|
||||
}
|
||||
} else if (stationObs) {
|
||||
alerted.stationOffline = false;
|
||||
}
|
||||
|
||||
sub.lastThresholdAlerted = alerted;
|
||||
saveSubs();
|
||||
@@ -1167,7 +1206,110 @@ app.get('/conditions', (_req, res) => {
|
||||
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);
|
||||
const data = cached?.data?.[0]?.lastData || null;
|
||||
if (!data) return res.json(null);
|
||||
res.json({ ...data, stationName: dashAuth?.stationName || 'Genesis 8:22' });
|
||||
});
|
||||
|
||||
// ── Dashboard management APIs ─────────────────────────────────────────────────
|
||||
|
||||
// Change password
|
||||
app.post('/api/dashboard/change-password', requireDashboardAuth, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body || {};
|
||||
if (!currentPassword || !newPassword || newPassword.length < 6)
|
||||
return res.status(400).json({ error: 'Invalid input' });
|
||||
const hash = hashPassword(currentPassword, dashAuth.salt);
|
||||
let match = false;
|
||||
try { match = crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(dashAuth.hash, 'hex')); } catch {}
|
||||
if (!match) return res.status(403).json({ error: 'Current password incorrect' });
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
dashAuth = { ...dashAuth, salt, hash: hashPassword(newPassword, salt) };
|
||||
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Station name
|
||||
app.get('/api/dashboard/station-name', requireDashboardAuth, (_req, res) => {
|
||||
res.json({ name: dashAuth?.stationName || 'Genesis 8:22' });
|
||||
});
|
||||
app.post('/api/dashboard/station-name', requireDashboardAuth, (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
if (!name || typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'Invalid name' });
|
||||
dashAuth = { ...dashAuth, stationName: name.trim().slice(0, 60) };
|
||||
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
|
||||
res.json({ ok: true, name: dashAuth.stationName });
|
||||
});
|
||||
|
||||
// Monthly summary — avg temp, total rain, high temp, high gust per month
|
||||
app.get('/api/station/monthly-summary', requireDashboardAuth, (_req, res) => {
|
||||
let files;
|
||||
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return res.json([]); }
|
||||
const months = {};
|
||||
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;
|
||||
const key = new Date(r.dateutc).toISOString().slice(0, 7); // YYYY-MM
|
||||
if (!months[key]) months[key] = { temps: [], highTemp: null, highGust: null, dailyRainMax: {} };
|
||||
const m = months[key];
|
||||
if (r.tempf != null) {
|
||||
m.temps.push(r.tempf);
|
||||
if (m.highTemp == null || r.tempf > m.highTemp) m.highTemp = r.tempf;
|
||||
}
|
||||
if (r.windgustmph != null && (m.highGust == null || r.windgustmph > m.highGust)) m.highGust = r.windgustmph;
|
||||
if (r.dailyrainin != null) {
|
||||
const day = stationDateKey(r.dateutc);
|
||||
if (m.dailyRainMax[day] == null || r.dailyrainin > m.dailyRainMax[day]) m.dailyRainMax[day] = r.dailyrainin;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const result = Object.entries(months).sort(([a], [b]) => a.localeCompare(b)).map(([key, m]) => ({
|
||||
month: key,
|
||||
avgTemp: m.temps.length ? +(m.temps.reduce((a, b) => a + b, 0) / m.temps.length).toFixed(1) : null,
|
||||
highTemp: m.highTemp != null ? +m.highTemp.toFixed(1) : null,
|
||||
highGust: m.highGust != null ? +m.highGust.toFixed(0) : null,
|
||||
totalRain: +Object.values(m.dailyRainMax).reduce((a, b) => a + b, 0).toFixed(2),
|
||||
readings: m.temps.length,
|
||||
}));
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Subscriptions list (for reading thresholds in dashboard)
|
||||
app.get('/api/subscriptions', requireDashboardAuth, (_req, res) => {
|
||||
res.json(pushSubscriptions.map(s => ({ ntfyTopic: s.ntfyTopic, thresholds: s.thresholds || {}, hasWebPush: !!s.subscription })));
|
||||
});
|
||||
|
||||
// Update thresholds on all subscriptions at once
|
||||
app.post('/api/subscriptions/thresholds', requireDashboardAuth, (req, res) => {
|
||||
const { thresholds } = req.body || {};
|
||||
if (!thresholds || typeof thresholds !== 'object') return res.status(400).json({ error: 'Invalid' });
|
||||
for (const sub of pushSubscriptions) sub.thresholds = { ...(sub.thresholds || {}), ...thresholds };
|
||||
saveSubs();
|
||||
res.json({ ok: true, updated: pushSubscriptions.length });
|
||||
});
|
||||
|
||||
// Trend data — last reading vs 1h ago for each tile
|
||||
app.get('/api/station/trend', requireDashboardAuth, (_req, res) => {
|
||||
const hour = loadStationReadings(3600000);
|
||||
if (hour.length < 2) return res.json({});
|
||||
const now = hour[hour.length - 1];
|
||||
const hourAgo = hour[0];
|
||||
const diff = (field) => {
|
||||
const a = hourAgo[field], b = now[field];
|
||||
if (a == null || b == null) return null;
|
||||
const d = b - a;
|
||||
return d > 0.1 ? 'up' : d < -0.1 ? 'down' : 'flat';
|
||||
};
|
||||
res.json({
|
||||
tempf: diff('tempf'), humidity: diff('humidity'), dewPoint: diff('dewPoint'),
|
||||
windspeedmph: diff('windspeedmph'), baromrelin: diff('baromrelin'),
|
||||
uv: diff('uv'), solarradiation: diff('solarradiation'),
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
|
||||
Reference in new issue
Block a user