diff --git a/app/public/conditions.html b/app/public/conditions.html
index 256e011..dbccd21 100644
--- a/app/public/conditions.html
+++ b/app/public/conditions.html
@@ -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);
diff --git a/app/public/dashboard.html b/app/public/dashboard.html
index d6a1e65..c988e0a 100644
--- a/app/public/dashboard.html
+++ b/app/public/dashboard.html
@@ -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; } }
@@ -127,23 +152,25 @@ a { color: var(--cool); text-decoration: none; }
+
+
@@ -191,6 +218,22 @@ a { color: var(--cool); text-decoration: none; }
+
+
+ Monthly Summary
+
+
+
+
+
+ Temperature Calendar
+
+
+
Station API Keys
@@ -256,6 +299,45 @@ a { color: var(--cool); text-decoration: none; }
+
+
+
+ Alert Thresholds (Station)
+ Leave blank to disable. These extend your existing push/ntfy alert subscriptions.
+
+
+
+
+
+
+
+
+ Station Display
+
+
+
+
+
+
+
+
+
+
+
+ Change Password
+
+
+
+
+
@@ -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 = 'No monthly data yet.
'; 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 `
+ | ${label} |
+ ${m.avgTemp != null ? m.avgTemp.toFixed(1) + '°F' : '—'} |
+ ${m.highTemp != null ? m.highTemp.toFixed(1) + '°F' : '—'} |
+ ${m.highGust != null ? m.highGust + ' mph' : '—'} |
+ ${m.totalRain != null ? m.totalRain.toFixed(2) + '"' : '—'} |
+ ${m.readings.toLocaleString()} |
+
`;
+ }).join('');
+ body.innerHTML = `
+ | Month | Avg Temp | High Temp | Max Gust | Total Rain | Readings |
+ ${rows}
`;
+ } catch { document.getElementById('monthlyBody').innerHTML = 'Failed to load.
'; }
+ }, { 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 = 'No data yet.
'; 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 ``;
+ const t = c.temp;
+ const label = t != null ? `${c.date}: ${t.toFixed(1)}°F` : c.date;
+ return ``;
+ }).join('');
+ body.innerHTML = `
+
+
+
Cooler
+ ${[20,35,50,65,80,95,110].map(t => `
`).join('')}
+
Hotter
+
`;
+ } catch { document.getElementById('heatmapBody').innerHTML = 'Failed to load.
'; }
+ }, { 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);