Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2436 lines
114 KiB
JavaScript
2436 lines
114 KiB
JavaScript
const express = require('express');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const crypto = require('crypto');
|
||
const webpush = require('web-push');
|
||
|
||
const app = express();
|
||
const PORT = process.env.PORT || 3000;
|
||
const weatherCache = new Map();
|
||
const WEATHER_CACHE_MAX_ENTRIES = 500;
|
||
|
||
// ── Persistent data directory ─────────────────────────────────────────────────
|
||
const DATA_DIR = path.join(__dirname, 'data');
|
||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||
|
||
// ── Dashboard auth ────────────────────────────────────────────────────────────
|
||
const DASH_AUTH_FILE = path.join(DATA_DIR, 'dashboard-auth.json');
|
||
const DASH_SECRET_FILE = path.join(DATA_DIR, 'dashboard-secret.txt');
|
||
|
||
let dashAuth = null;
|
||
try { dashAuth = JSON.parse(fs.readFileSync(DASH_AUTH_FILE, 'utf8')); } catch {}
|
||
|
||
let SESSION_SECRET;
|
||
try { SESSION_SECRET = fs.readFileSync(DASH_SECRET_FILE, 'utf8').trim(); } catch {}
|
||
if (!SESSION_SECRET) {
|
||
SESSION_SECRET = crypto.randomBytes(32).toString('hex');
|
||
try { fs.writeFileSync(DASH_SECRET_FILE, SESSION_SECRET); } catch {}
|
||
}
|
||
|
||
function parseCookies(req) {
|
||
const raw = req.headers.cookie || '';
|
||
return Object.fromEntries(raw.split(';').map(c => {
|
||
const [k, ...v] = c.trim().split('=');
|
||
return [k ? k.trim() : '', v.join('=')];
|
||
}).filter(([k]) => k));
|
||
}
|
||
|
||
// Sessions last SESSION_DAYS and slide: any signed-in request more than a day after the
|
||
// cookie was issued gets a fresh one, so people who keep using the app never sign in again.
|
||
const SESSION_DAYS = 180;
|
||
const SESSION_MS = SESSION_DAYS * 24 * 60 * 60 * 1000;
|
||
const SESSION_RENEW_AFTER_MS = 24 * 60 * 60 * 1000;
|
||
|
||
function signSession(username, epoch = 0) {
|
||
const expiry = Date.now() + SESSION_MS;
|
||
const payload = Buffer.from(`${username}|${expiry}|${epoch}`).toString('base64url');
|
||
const sig = crypto.createHmac('sha256', SESSION_SECRET).update(payload).digest('base64url');
|
||
return `${payload}.${sig}`;
|
||
}
|
||
|
||
// Returns { username, epoch } for a valid, unexpired token (epoch 0 for pre-users cookies)
|
||
function verifySession(token) {
|
||
if (!token || typeof token !== 'string') return null;
|
||
const dotIdx = token.lastIndexOf('.');
|
||
if (dotIdx < 1) return null;
|
||
const payload = token.slice(0, dotIdx);
|
||
const sig = token.slice(dotIdx + 1);
|
||
const expected = crypto.createHmac('sha256', SESSION_SECRET).update(payload).digest('base64url');
|
||
try {
|
||
if (!crypto.timingSafeEqual(Buffer.from(sig + '='.repeat((4 - sig.length % 4) % 4), 'base64'), Buffer.from(expected + '='.repeat((4 - expected.length % 4) % 4), 'base64'))) return null;
|
||
} catch { return null; }
|
||
const decoded = Buffer.from(payload, 'base64url').toString();
|
||
const [username, expiry, epoch] = decoded.split('|');
|
||
if (!username || !expiry || Date.now() > parseInt(expiry)) return null;
|
||
return { username, epoch: parseInt(epoch) || 0, expiry: parseInt(expiry) };
|
||
}
|
||
|
||
function sessionCookie(req, token) {
|
||
const secure = req.secure || req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : '';
|
||
return `dsession=${token}; HttpOnly; Path=/; Max-Age=${SESSION_DAYS * 24 * 3600}; SameSite=Lax${secure}`;
|
||
}
|
||
|
||
function hashPassword(password, salt) {
|
||
return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
|
||
}
|
||
|
||
function getDailyRecoveryToken() {
|
||
const day = Math.floor(Date.now() / (24 * 60 * 60 * 1000));
|
||
return crypto.createHmac('sha256', SESSION_SECRET).update('recover:' + day).digest('hex').slice(0, 16);
|
||
}
|
||
|
||
// ── Users & roles ─────────────────────────────────────────────────────────────
|
||
// data/users.json: { users: [{ username, salt, hash, role: 'admin'|'viewer', disabled, epoch, createdAt, lastLogin }] }
|
||
// `epoch` is bumped on password change/reset/disable so that user's existing sessions stop working.
|
||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||
const PASSWORD_MIN = 8;
|
||
const USERNAME_RE = /^[A-Za-z0-9._-]{3,32}$/;
|
||
const ROLES = ['admin', 'viewer'];
|
||
let usersStore = { users: [] };
|
||
try { usersStore = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch {}
|
||
if (!usersStore || !Array.isArray(usersStore.users)) usersStore = { users: [] };
|
||
|
||
function saveUsers() {
|
||
try {
|
||
fs.writeFileSync(USERS_FILE + '.tmp', JSON.stringify(usersStore, null, 2), { mode: 0o600 });
|
||
fs.renameSync(USERS_FILE + '.tmp', USERS_FILE);
|
||
} catch (e) { console.error('[auth] could not save users:', e.message); }
|
||
}
|
||
|
||
// First run after upgrade: the existing single account becomes the first admin.
|
||
if (!usersStore.users.length && dashAuth?.username && dashAuth.hash && dashAuth.salt) {
|
||
usersStore.users.push({ username: dashAuth.username, salt: dashAuth.salt, hash: dashAuth.hash, role: 'admin', epoch: 0, createdAt: Date.now() });
|
||
saveUsers();
|
||
console.log(`[auth] migrated existing account "${dashAuth.username}" to admin`);
|
||
}
|
||
|
||
const hasUsers = () => usersStore.users.length > 0;
|
||
const findUser = (name) => usersStore.users.find(u => u.username.toLowerCase() === String(name || '').toLowerCase());
|
||
const activeAdmins = () => usersStore.users.filter(u => u.role === 'admin' && !u.disabled);
|
||
const publicUser = (u) => ({ username: u.username, role: u.role, disabled: !!u.disabled, createdAt: u.createdAt || null, lastLogin: u.lastLogin || null });
|
||
|
||
const DUMMY_SALT = crypto.randomBytes(16).toString('hex');
|
||
function checkPassword(user, password) {
|
||
const hash = hashPassword(String(password || ''), user ? user.salt : DUMMY_SALT); // always hash: no timing hint about unknown users
|
||
if (!user) return false;
|
||
try { return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(user.hash, 'hex')); } catch { return false; }
|
||
}
|
||
function setPassword(user, password) {
|
||
user.salt = crypto.randomBytes(16).toString('hex');
|
||
user.hash = hashPassword(password, user.salt);
|
||
user.epoch = (user.epoch || 0) + 1; // signs out every existing session for this user
|
||
}
|
||
|
||
// Simple brute-force guard: 5 failures within 15 min locks that username+IP for 5 min.
|
||
const loginAttempts = new Map();
|
||
const attemptKey = (req, username) => `${req.ip}|${String(username || '').toLowerCase()}`;
|
||
function isLockedOut(key) {
|
||
const a = loginAttempts.get(key);
|
||
return !!(a && a.lockedUntil && a.lockedUntil > Date.now());
|
||
}
|
||
function noteLoginFailure(key) {
|
||
const now = Date.now();
|
||
const a = loginAttempts.get(key);
|
||
const cur = a && now - a.first < 15 * 60 * 1000 ? a : { fails: 0, first: now };
|
||
cur.fails++;
|
||
if (cur.fails >= 5) cur.lockedUntil = now + 5 * 60 * 1000;
|
||
loginAttempts.set(key, cur);
|
||
}
|
||
setInterval(() => { const now = Date.now(); for (const [k, a] of loginAttempts) if (now - a.first > 15 * 60 * 1000 && !(a.lockedUntil > now)) loginAttempts.delete(k); }, 10 * 60 * 1000).unref();
|
||
|
||
function clearAttemptsFor(username) {
|
||
const suffix = '|' + String(username).toLowerCase();
|
||
for (const k of loginAttempts.keys()) if (k.endsWith(suffix)) loginAttempts.delete(k);
|
||
}
|
||
|
||
function currentUser(req) {
|
||
const s = verifySession(parseCookies(req).dsession);
|
||
if (!s) return null;
|
||
const u = findUser(s.username);
|
||
if (!u || u.disabled || (u.epoch || 0) !== s.epoch) return null;
|
||
req.sessionExpiry = s.expiry;
|
||
return u;
|
||
}
|
||
|
||
function requireDashboardAuth(req, res, next) {
|
||
const user = currentUser(req);
|
||
if (user) {
|
||
req.dashUser = user;
|
||
// Sliding renewal (routes that set their own cookie later simply overwrite this header)
|
||
if (req.sessionExpiry - Date.now() < SESSION_MS - SESSION_RENEW_AFTER_MS) {
|
||
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch || 0)));
|
||
}
|
||
return next();
|
||
}
|
||
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Unauthorized' });
|
||
res.redirect('/dashboard/login');
|
||
}
|
||
|
||
function requireAdmin(req, res, next) {
|
||
if (req.dashUser?.role === 'admin') return next();
|
||
if (req.path.startsWith('/api/')) return res.status(403).json({ error: 'Admin access required' });
|
||
res.redirect('/dashboard');
|
||
}
|
||
|
||
// ── Station data recording ────────────────────────────────────────────────────
|
||
const STATION_DIR = path.join(DATA_DIR, 'station');
|
||
if (!fs.existsSync(STATION_DIR)) fs.mkdirSync(STATION_DIR, { recursive: true });
|
||
|
||
// ── Alert history log ─────────────────────────────────────────────────────────
|
||
const ALERTS_DIR = path.join(DATA_DIR, 'alerts');
|
||
if (!fs.existsSync(ALERTS_DIR)) fs.mkdirSync(ALERTS_DIR, { recursive: true });
|
||
|
||
function recordAlertLog(entry) {
|
||
try {
|
||
const day = new Date().toISOString().slice(0, 10);
|
||
const file = path.join(ALERTS_DIR, `${day}.jsonl`);
|
||
fs.appendFileSync(file, JSON.stringify({ ts: Date.now(), ...entry }) + '\n');
|
||
} catch (e) {
|
||
console.error('[alerts] log error:', e.message);
|
||
}
|
||
}
|
||
|
||
// Fields worth keeping from an NWS alert so the history can say what it was for and where
|
||
function nwsLogFields(p) {
|
||
return { event: p.event, headline: p.headline, description: p.description, instruction: p.instruction,
|
||
areaDesc: p.areaDesc, severity: p.severity, urgency: p.urgency, certainty: p.certainty, senderName: p.senderName,
|
||
effective: p.effective, expires: p.expires };
|
||
}
|
||
|
||
// Tracks NWS alert IDs already written this process lifetime to avoid duplicate log lines
|
||
// across multiple subscribers covering the same geographic alert.
|
||
const loggedAlertIds = new Set();
|
||
|
||
function stationDateKey(ts) {
|
||
return new Date(ts).toISOString().slice(0, 10);
|
||
}
|
||
|
||
// Station "day" = local calendar day, since the station resets dailyrainin at local midnight.
|
||
// Uses the timezone stamped on each reading (falls back to the last one seen / America/New_York).
|
||
let stationTzFallback = 'America/New_York';
|
||
const dayFmtCache = new Map();
|
||
function stationLocalDay(r) {
|
||
const tz = r.tz || stationTzFallback;
|
||
let f = dayFmtCache.get(tz);
|
||
if (!f) {
|
||
try { f = new Intl.DateTimeFormat('en-CA', { timeZone: tz }); }
|
||
catch { f = new Intl.DateTimeFormat('en-CA', { timeZone: stationTzFallback }); }
|
||
dayFmtCache.set(tz, f);
|
||
}
|
||
return f.format(new Date(r.dateutc));
|
||
}
|
||
|
||
// Per-day set of dateutc values already on disk. Shared by the live recorder and
|
||
// the backfill so neither can write a reading the other (or a prior run) saved.
|
||
const stationSeen = new Map();
|
||
|
||
function stationDayFile(day) {
|
||
return path.join(STATION_DIR, `${day}.jsonl`);
|
||
}
|
||
|
||
function getStationSeen(day) {
|
||
let set = stationSeen.get(day);
|
||
if (set) return set;
|
||
set = new Set();
|
||
try {
|
||
for (const l of fs.readFileSync(stationDayFile(day), 'utf8').split('\n')) {
|
||
if (!l) continue;
|
||
try { set.add(JSON.parse(l).dateutc); } catch {}
|
||
}
|
||
} catch {}
|
||
stationSeen.set(day, set);
|
||
return set;
|
||
}
|
||
|
||
// A reading is only saved if it has a sane timestamp and isn't already stored.
|
||
// Returns true when a new row was written.
|
||
function saveStationReading(obs) {
|
||
const ts = obs && obs.dateutc;
|
||
if (!Number.isFinite(ts) || ts <= 0 || ts > Date.now() + 5 * 60 * 1000) return false;
|
||
const day = stationDateKey(ts);
|
||
const seen = getStationSeen(day);
|
||
if (seen.has(ts)) return false;
|
||
fs.appendFileSync(stationDayFile(day), JSON.stringify(obs) + '\n');
|
||
seen.add(ts);
|
||
if (typeof updateRecordsFromReading === 'function') {
|
||
try { updateRecordsFromReading(obs); } catch (e) { console.error('[records] update error:', e.message); }
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function recordStationReading(obs) {
|
||
try {
|
||
if (obs && obs.tz) stationTzFallback = obs.tz;
|
||
saveStationReading(obs);
|
||
} catch (e) {
|
||
console.error('[station] record error:', e.message);
|
||
}
|
||
}
|
||
|
||
// One-time startup pass: drop duplicate dateutc rows from existing day files.
|
||
// Rewrites a file (tmp + rename) only when it actually contains duplicates.
|
||
function dedupeStationFiles() {
|
||
let files;
|
||
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')); } catch { return; }
|
||
for (const f of files) {
|
||
try {
|
||
const file = path.join(STATION_DIR, f);
|
||
const seen = new Set();
|
||
const keep = [];
|
||
let dropped = 0;
|
||
for (const l of fs.readFileSync(file, 'utf8').split('\n')) {
|
||
if (!l) continue;
|
||
let ts;
|
||
try { ts = JSON.parse(l).dateutc; } catch { keep.push(l); continue; }
|
||
if (ts != null && seen.has(ts)) { dropped++; continue; }
|
||
seen.add(ts);
|
||
keep.push(l);
|
||
}
|
||
if (dropped) {
|
||
fs.writeFileSync(file + '.tmp', keep.join('\n') + '\n');
|
||
fs.renameSync(file + '.tmp', file);
|
||
console.log(`[station] ${f}: removed ${dropped} duplicate reading(s)`);
|
||
}
|
||
} catch (e) {
|
||
console.error('[station] dedupe error:', f, e.message);
|
||
}
|
||
}
|
||
}
|
||
dedupeStationFiles();
|
||
|
||
// ── Persistent all-time records ───────────────────────────────────────────────
|
||
// data/records.json keeps one small summary per local day (extremes + the time they
|
||
// happened). All-time / monthly / yearly records are derived from it, so they survive
|
||
// even if raw readings are pruned or lost. Merging only ever widens an extreme.
|
||
const RECORDS_FILE = path.join(DATA_DIR, 'records.json');
|
||
let recordsStore = { version: 1, days: {} };
|
||
try {
|
||
const loaded = JSON.parse(fs.readFileSync(RECORDS_FILE, 'utf8'));
|
||
if (loaded && loaded.days) recordsStore = loaded;
|
||
} catch {}
|
||
let recordsDirty = false;
|
||
|
||
// [field, source key(s), 'max' | 'min']
|
||
const DAY_FIELDS = [
|
||
['hi', ['tempf'], 'max'], ['lo', ['tempf'], 'min'],
|
||
['gust', ['windgustmph', 'maxdailygust'], 'max'],
|
||
['rate', ['rainratein'], 'max'],
|
||
['uv', ['uv'], 'max'], ['solar', ['solarradiation'], 'max'],
|
||
['pmax', ['baromrelin'], 'max'], ['pmin', ['baromrelin'], 'min'],
|
||
['hmax', ['humidity'], 'max'], ['hmin', ['humidity'], 'min'],
|
||
];
|
||
|
||
function mergeExtreme(day, field, kind, val, ts) {
|
||
if (!Number.isFinite(val)) return false;
|
||
const cur = day[field];
|
||
if (!cur || (kind === 'max' ? val > cur[0] : val < cur[0])) { day[field] = [val, ts]; return true; }
|
||
return false;
|
||
}
|
||
|
||
function applyReadingToRecords(obs) {
|
||
const dayKey = stationLocalDay(obs);
|
||
const day = recordsStore.days[dayKey] || (recordsStore.days[dayKey] = { n: 0 });
|
||
let changed = false;
|
||
for (const [field, keys, kind] of DAY_FIELDS) {
|
||
for (const k of keys) {
|
||
if (obs[k] != null && mergeExtreme(day, field, kind, Number(obs[k]), obs.dateutc)) changed = true;
|
||
}
|
||
}
|
||
if (obs.dailyrainin != null && Number.isFinite(Number(obs.dailyrainin)) && (day.rain == null || Number(obs.dailyrainin) > day.rain)) {
|
||
day.rain = Number(obs.dailyrainin); changed = true;
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
// Non-idempotent aggregates (sum/count/direction histogram). Applied once per saved reading;
|
||
// a rebuild recomputes them from the raw files instead of adding on top.
|
||
function addAggregates(agg, obs) {
|
||
if (Number.isFinite(Number(obs.tempf)) && obs.tempf != null) { agg.tsum = (agg.tsum || 0) + Number(obs.tempf); agg.tn = (agg.tn || 0) + 1; }
|
||
if (obs.winddir != null && Number.isFinite(Number(obs.winddir)) && Number(obs.windspeedmph) > 0.5) {
|
||
const wd = agg.wd || (agg.wd = new Array(16).fill(0));
|
||
wd[Math.round(Number(obs.winddir) / 22.5) % 16]++;
|
||
}
|
||
}
|
||
|
||
function saveRecordsStore() {
|
||
if (!recordsDirty) return;
|
||
try {
|
||
fs.writeFileSync(RECORDS_FILE + '.tmp', JSON.stringify(recordsStore));
|
||
fs.renameSync(RECORDS_FILE + '.tmp', RECORDS_FILE);
|
||
recordsDirty = false;
|
||
} catch (e) { console.error('[records] save error:', e.message); }
|
||
}
|
||
|
||
function updateRecordsFromReading(obs) {
|
||
applyReadingToRecords(obs);
|
||
const day = recordsStore.days[stationLocalDay(obs)];
|
||
day.n = (day.n || 0) + 1;
|
||
addAggregates(day, obs);
|
||
recordsDirty = true;
|
||
}
|
||
|
||
// Re-scan raw readings and merge into the store (never lowers an existing record).
|
||
function rebuildRecordsFromFiles() {
|
||
let files;
|
||
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return 0; }
|
||
const counts = {};
|
||
const fresh = {};
|
||
let scanned = 0;
|
||
for (const f of files) {
|
||
try {
|
||
const seen = new Set();
|
||
for (const l of fs.readFileSync(path.join(STATION_DIR, f), 'utf8').split('\n')) {
|
||
if (!l) continue;
|
||
let r; try { r = JSON.parse(l); } catch { continue; }
|
||
if (!Number.isFinite(r.dateutc) || seen.has(r.dateutc)) continue;
|
||
seen.add(r.dateutc);
|
||
applyReadingToRecords(r);
|
||
const k = stationLocalDay(r);
|
||
counts[k] = (counts[k] || 0) + 1;
|
||
addAggregates(fresh[k] || (fresh[k] = {}), r);
|
||
scanned++;
|
||
}
|
||
} catch {}
|
||
}
|
||
// Reading counts come from the files; keep the larger so pruned history isn't forgotten.
|
||
for (const [k, n] of Object.entries(counts)) {
|
||
const day = recordsStore.days[k];
|
||
day.n = Math.max(day.n || 0, n);
|
||
// Replace aggregates only when the files hold at least as many samples as the store did
|
||
const f = fresh[k];
|
||
if (f.tn != null && f.tn >= (day.tn || 0)) { day.tsum = f.tsum; day.tn = f.tn; }
|
||
if (f.wd && (!day.wd || f.wd.reduce((a, b) => a + b, 0) >= day.wd.reduce((a, b) => a + b, 0))) day.wd = f.wd;
|
||
}
|
||
recordsDirty = true;
|
||
saveRecordsStore();
|
||
return scanned;
|
||
}
|
||
|
||
function deriveRecords() {
|
||
const days = Object.entries(recordsStore.days).sort(([a], [b]) => a.localeCompare(b));
|
||
if (!days.length) return null;
|
||
const top = (field, kind) => {
|
||
let best = null, bestDay = null;
|
||
for (const [d, v] of days) {
|
||
const e = v[field]; if (!e) continue;
|
||
if (best == null || (kind === 'max' ? e[0] > best[0] : e[0] < best[0])) { best = e; bestDay = d; }
|
||
}
|
||
return best ? { value: best[0], ts: best[1], day: bestDay } : null;
|
||
};
|
||
const rec = {
|
||
highTemp: top('hi', 'max'), lowTemp: top('lo', 'min'), highGust: top('gust', 'max'),
|
||
highRainRate: top('rate', 'max'), highUV: top('uv', 'max'), highSolar: top('solar', 'max'),
|
||
highPressure: top('pmax', 'max'), lowPressure: top('pmin', 'min'),
|
||
highHumidity: top('hmax', 'max'), lowHumidity: top('hmin', 'min'),
|
||
};
|
||
let wet = null;
|
||
for (const [d, v] of days) if (v.rain != null && (!wet || v.rain > wet.value)) wet = { value: v.rain, day: d };
|
||
rec.highDailyRain = wet;
|
||
|
||
const out = {};
|
||
const flat = {
|
||
highTemp: 'highTemp', lowTemp: 'lowTemp', highGust: 'highGust', highRainRate: 'highRainRate',
|
||
highDailyRain: 'highDailyRain', highUV: 'highUV', highSolar: 'highSolar',
|
||
highPressure: 'highPressure', lowPressure: 'lowPressure', highHumidity: 'highHumidity', lowHumidity: 'lowHumidity',
|
||
};
|
||
for (const [k, src] of Object.entries(flat)) {
|
||
out[k] = rec[src] ? rec[src].value : null;
|
||
out[k + 'Date'] = rec[src] ? (rec[src].ts || Date.parse(rec[src].day + 'T12:00:00Z')) : null;
|
||
}
|
||
out.totalReadings = days.reduce((a, [, v]) => a + (v.n || 0), 0);
|
||
out.firstDay = days[0][0];
|
||
out.lastDay = days[days.length - 1][0];
|
||
out.firstReading = Date.parse(out.firstDay + 'T12:00:00Z');
|
||
out.lastReading = Date.parse(out.lastDay + 'T12:00:00Z');
|
||
out.daysRecorded = days.length;
|
||
|
||
// Notable days
|
||
const dayRec = (field, kind) => { const t = top(field, kind); return t ? { value: t.value, day: t.day } : null; };
|
||
out.notable = {
|
||
hottestDay: dayRec('hi', 'max'),
|
||
coldestDay: dayRec('lo', 'min'),
|
||
coolestHigh: dayRec('hi', 'min'),
|
||
warmestLow: dayRec('lo', 'max'),
|
||
windiestDay: dayRec('gust', 'max'),
|
||
wettestDay: rec.highDailyRain,
|
||
};
|
||
|
||
// Per-period aggregation (month-of-year and calendar year)
|
||
const agg = (keyFn) => {
|
||
const g = {};
|
||
for (const [d, v] of days) {
|
||
const k = keyFn(d); const a = g[k] || (g[k] = { days: 0, rain: 0, hi: null, lo: null, gust: null, tsum: 0, tn: 0 });
|
||
a.days++; a.rain += v.rain || 0;
|
||
if (v.tn) { a.tsum += v.tsum; a.tn += v.tn; }
|
||
if (v.hi && (a.hi == null || v.hi[0] > a.hi)) a.hi = v.hi[0];
|
||
if (v.lo && (a.lo == null || v.lo[0] < a.lo)) a.lo = v.lo[0];
|
||
if (v.gust && (a.gust == null || v.gust[0] > a.gust)) a.gust = v.gust[0];
|
||
}
|
||
for (const a of Object.values(g)) {
|
||
a.rain = +a.rain.toFixed(2);
|
||
a.avg = a.tn ? +(a.tsum / a.tn).toFixed(1) : null;
|
||
delete a.tsum; delete a.tn;
|
||
}
|
||
return g;
|
||
};
|
||
const byYear = agg(d => d.slice(0, 4));
|
||
out.years = Object.entries(byYear).sort(([a], [b]) => a.localeCompare(b)).map(([year, a]) => ({ year, ...a }));
|
||
const byMonth = agg(d => d.slice(0, 7));
|
||
const ym = Object.entries(byMonth).sort(([a], [b]) => a.localeCompare(b)).map(([month, a]) => ({ month, ...a }));
|
||
out.months = ym;
|
||
out.wettestMonth = ym.reduce((b, m) => (!b || m.rain > b.rain ? m : b), null);
|
||
out.wettestYear = out.years.reduce((b, y) => (!b || y.rain > b.rain ? y : b), null);
|
||
|
||
// Month / year records (partial periods are flagged so "coldest month" can't be a 2-week stub unnoticed)
|
||
const dim = (ym) => new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0).getDate();
|
||
const mk = (p, key, val, total) => ({ key, value: val, days: p.days, partial: p.days < total * 0.9 });
|
||
// preferComplete: averages / "driest" are only fair over whole periods, so rank complete ones first and
|
||
// fall back to partial ones only when no complete period exists yet. Totals and extremes stay valid when partial.
|
||
const pick = (list, keyName, valFn, dir, totalFn, preferComplete) => {
|
||
const rank = (onlyComplete) => {
|
||
let best = null;
|
||
for (const p of list) {
|
||
const v = valFn(p); if (v == null) continue;
|
||
const total = totalFn(p[keyName]);
|
||
if (onlyComplete && p.days < total * 0.9) continue;
|
||
if (!best || (dir === 'max' ? v > best.value : v < best.value)) best = mk(p, p[keyName], v, total);
|
||
}
|
||
return best;
|
||
};
|
||
return (preferComplete && rank(true)) || rank(false);
|
||
};
|
||
const mPick = (valFn, dir, preferComplete) => pick(ym, 'month', valFn, dir, dim, preferComplete);
|
||
const yPick = (valFn, dir, preferComplete) => pick(out.years, 'year', valFn, dir, () => 365, preferComplete);
|
||
out.periods = {
|
||
months: {
|
||
wettest: mPick(p => p.rain, 'max'), driest: mPick(p => p.rain, 'min', true),
|
||
hottest: mPick(p => p.avg, 'max', true), coldest: mPick(p => p.avg, 'min', true), windiest: mPick(p => p.gust, 'max'),
|
||
},
|
||
years: {
|
||
wettest: yPick(p => p.rain, 'max'), hottest: yPick(p => p.avg, 'max', true),
|
||
coldest: yPick(p => p.avg, 'min', true), windiest: yPick(p => p.gust, 'max'),
|
||
},
|
||
};
|
||
|
||
// Records for each calendar month across every year ("record July", "record January", …)
|
||
const cal = Array.from({ length: 12 }, () => ({ hi: null, lo: null, wettestDay: null, gust: null, wettestTotal: null, years: new Set() }));
|
||
const monthRain = {};
|
||
for (const [d, v] of days) {
|
||
const m = +d.slice(5, 7) - 1, c = cal[m];
|
||
c.years.add(d.slice(0, 4));
|
||
if (v.hi && (!c.hi || v.hi[0] > c.hi.value)) c.hi = { value: v.hi[0], day: d };
|
||
if (v.lo && (!c.lo || v.lo[0] < c.lo.value)) c.lo = { value: v.lo[0], day: d };
|
||
if (v.rain != null && v.rain > 0 && (!c.wettestDay || v.rain > c.wettestDay.value)) c.wettestDay = { value: v.rain, day: d };
|
||
if (v.gust && (!c.gust || v.gust[0] > c.gust.value)) c.gust = { value: v.gust[0], day: d };
|
||
monthRain[d.slice(0, 7)] = (monthRain[d.slice(0, 7)] || 0) + (v.rain || 0);
|
||
}
|
||
for (const [ymKey, total] of Object.entries(monthRain)) {
|
||
const c = cal[+ymKey.slice(5, 7) - 1];
|
||
if (total > 0 && (!c.wettestTotal || total > c.wettestTotal.value)) c.wettestTotal = { value: +total.toFixed(2), month: ymKey };
|
||
}
|
||
out.calendarMonths = cal.map((c, i) => ({ month: i + 1, years: c.years.size, hi: c.hi, lo: c.lo, wettestDay: c.wettestDay, gust: c.gust, wettestTotal: c.wettestTotal }));
|
||
return out;
|
||
}
|
||
|
||
try {
|
||
if (!Object.keys(recordsStore.days).length) console.log(`[records] built from ${rebuildRecordsFromFiles()} readings`);
|
||
else rebuildRecordsFromFiles();
|
||
} catch (e) { console.error('[records] startup rebuild error:', e.message); }
|
||
setInterval(saveRecordsStore, 30 * 1000).unref();
|
||
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { saveRecordsStore(); try { saveAlertDetails(); } catch {} process.exit(0); });
|
||
|
||
function loadStationReadings(rangeMs) {
|
||
const now = Date.now();
|
||
const cutoff = now - rangeMs;
|
||
const days = Math.ceil(rangeMs / 86400000) + 1;
|
||
const results = [];
|
||
const seen = new Set();
|
||
for (let i = 0; i < days; i++) {
|
||
const day = stationDateKey(now - i * 86400000);
|
||
const file = stationDayFile(day);
|
||
try {
|
||
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
||
for (const line of lines) {
|
||
try {
|
||
const obj = JSON.parse(line);
|
||
const ts = obj.dateutc || 0;
|
||
if (ts >= cutoff && !seen.has(ts)) { seen.add(ts); results.push(obj); }
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
}
|
||
return results.sort((a, b) => (a.dateutc || 0) - (b.dateutc || 0));
|
||
}
|
||
|
||
// ── VAPID web push ────────────────────────────────────────────────────────────
|
||
const VAPID_FILE = path.join(DATA_DIR, 'vapid.json');
|
||
let vapidKeys;
|
||
try {
|
||
vapidKeys = JSON.parse(fs.readFileSync(VAPID_FILE, 'utf8'));
|
||
} catch {
|
||
vapidKeys = webpush.generateVAPIDKeys();
|
||
fs.writeFileSync(VAPID_FILE, JSON.stringify(vapidKeys));
|
||
console.log('[vapid] Generated new keys. Public key:', vapidKeys.publicKey);
|
||
}
|
||
webpush.setVapidDetails('mailto:nate@necloud.us', vapidKeys.publicKey, vapidKeys.privateKey);
|
||
|
||
// ── Push subscription store ───────────────────────────────────────────────────
|
||
const SUBS_FILE = path.join(DATA_DIR, 'subscriptions.json');
|
||
let pushSubscriptions = [];
|
||
try {
|
||
if (fs.existsSync(SUBS_FILE)) pushSubscriptions = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8'));
|
||
} catch { pushSubscriptions = []; }
|
||
function saveSubs() {
|
||
try { fs.writeFileSync(SUBS_FILE, JSON.stringify(pushSubscriptions, null, 2)); } catch {}
|
||
}
|
||
|
||
function setCache(key, data) {
|
||
if (weatherCache.size >= WEATHER_CACHE_MAX_ENTRIES) {
|
||
const oldestKey = weatherCache.keys().next().value;
|
||
weatherCache.delete(oldestKey);
|
||
}
|
||
weatherCache.set(key, { data, at: Date.now() });
|
||
}
|
||
|
||
// Fetch with retry/timeout, shared by all upstream proxy calls.
|
||
async function fetchWithRetry(url, { attempts = 2, timeoutMs = 12000, retryDelayMs = 500, fetchOptions } = {}) {
|
||
let lastError = null;
|
||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const r = await fetch(url, { ...fetchOptions, signal: controller.signal });
|
||
clearTimeout(timeoutId);
|
||
if (!r.ok) throw new Error(`Upstream status ${r.status}`);
|
||
return r;
|
||
} catch (err) {
|
||
clearTimeout(timeoutId);
|
||
lastError = err;
|
||
if (attempt < attempts) {
|
||
await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
|
||
}
|
||
}
|
||
}
|
||
throw lastError;
|
||
}
|
||
|
||
function isValidCoord(value, min, max) {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) && n >= min && n <= max;
|
||
}
|
||
|
||
// ── Cache-proof asset URLs ───────────────────────────────────────────────────
|
||
// A reverse proxy in front of the app was serving stale copies of static files (old app.js / sw.js)
|
||
// next to new HTML. HTML is always fetched fresh, so every local script, stylesheet, icon and manifest
|
||
// it references gets a content-hash version (app.js?v=ab12cd34): any change is a brand-new URL that no
|
||
// proxy or phone has cached.
|
||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||
const assetHashes = new Map(); // absolute path -> { mtimeMs, hash }
|
||
function assetVersion(url) {
|
||
const rel = url.replace(/^\//, '');
|
||
const abs = path.join(PUBLIC_DIR, rel);
|
||
if (!abs.startsWith(PUBLIC_DIR)) return null;
|
||
try {
|
||
const st = fs.statSync(abs);
|
||
const hit = assetHashes.get(abs);
|
||
if (hit && hit.mtimeMs === st.mtimeMs) return hit.hash;
|
||
const hash = crypto.createHash('sha1').update(fs.readFileSync(abs)).digest('hex').slice(0, 10);
|
||
assetHashes.set(abs, { mtimeMs: st.mtimeMs, hash });
|
||
return hash;
|
||
} catch { return null; }
|
||
}
|
||
const inlineIcons = new Map();
|
||
function inlineIcon(url) {
|
||
const abs = path.join(PUBLIC_DIR, url.replace(/^\//, ''));
|
||
if (!abs.startsWith(PUBLIC_DIR)) return null;
|
||
try {
|
||
const st = fs.statSync(abs);
|
||
const hit = inlineIcons.get(abs);
|
||
if (hit && hit.mtimeMs === st.mtimeMs) return hit.uri;
|
||
const uri = 'data:image/png;base64,' + fs.readFileSync(abs).toString('base64');
|
||
inlineIcons.set(abs, { mtimeMs: st.mtimeMs, uri });
|
||
return uri;
|
||
} catch { return null; }
|
||
}
|
||
function versionedHtml(file) {
|
||
let html = fs.readFileSync(path.join(PUBLIC_DIR, file), 'utf8');
|
||
html = html.replace(/\b(src|href)="([^"#?]+\.(?:js|css|png|svg|webmanifest|json))"/g, (m, attr, url) => {
|
||
if (/^(?:[a-z]+:)?\/\//i.test(url)) return m; // external (https://, //cdn)
|
||
const v = assetVersion(url);
|
||
return v ? `${attr}="${url}?v=${v}"` : m;
|
||
});
|
||
// iOS draws a letter tile if its separate icon download fails (proxy, network path, cached failure).
|
||
// Embed the 180px touch icon in the page itself so the image arrives with the HTML, no second request.
|
||
html = html.replace(/<link rel="apple-touch-icon" sizes="180x180" href="(\/icons\/v2\/[^"?]+\.png)(?:\?v=[0-9a-f]+)?">/, (m, url) => {
|
||
const uri = inlineIcon(url);
|
||
return uri ? `<link rel="apple-touch-icon" sizes="180x180" href="${uri}">` : m;
|
||
});
|
||
// the service worker script is cached by the same proxy, so version its URL too
|
||
const swV = assetVersion('/sw.js');
|
||
if (swV) html = html.replace(/register\('\/sw\.js'\)/g, `register('/sw.js?v=${swV}')`);
|
||
return html;
|
||
}
|
||
function sendHtml(res, file) {
|
||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
res.send(versionedHtml(file));
|
||
}
|
||
|
||
// Keep shell assets fresh so SW/app versions do not drift.
|
||
app.use('/', (req, res, next) => {
|
||
if (req.path === '/' || req.path.endsWith('.html')) {
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
} else if (
|
||
req.path.endsWith('.css') ||
|
||
req.path.endsWith('.js') ||
|
||
req.path === '/sw.js' ||
|
||
req.path === '/manifest.json'
|
||
) {
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
}
|
||
next();
|
||
});
|
||
|
||
app.get(['/', '/index.html'], (_req, res) => sendHtml(res, 'index.html'));
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
// Prevent browser favicon requests from showing noisy 404s.
|
||
// Icons iOS and browsers request from the site root when a page doesn't declare one
|
||
const ICON_DIR = path.join(__dirname, 'public', 'icons', 'v2');
|
||
app.get(['/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'], (_req, res) => res.sendFile(path.join(ICON_DIR, 'apple-touch-icon.png')));
|
||
app.get('/favicon.ico', (_req, res) => res.sendFile(path.join(ICON_DIR, 'icon-192.png')));
|
||
|
||
// Geocoding proxy
|
||
app.get('/api/geocode', async (req, res) => {
|
||
const { q } = req.query;
|
||
if (!q) return res.status(400).json({ error: 'Missing query' });
|
||
try {
|
||
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`;
|
||
|
||
let openMeteoData = null;
|
||
try {
|
||
const r = await fetchWithRetry(url, { attempts: 2, timeoutMs: 9000, retryDelayMs: 300 });
|
||
openMeteoData = await r.json();
|
||
} catch (err) {
|
||
console.warn('Open-Meteo geocode failed after retries:', err.message);
|
||
}
|
||
|
||
if (openMeteoData?.results?.length) {
|
||
return res.json(openMeteoData);
|
||
}
|
||
|
||
// Fallback to Nominatim when Open-Meteo geocoding is empty/unavailable.
|
||
const nomParams = new URLSearchParams({ q: String(q), format: 'jsonv2', limit: '5', addressdetails: '1' });
|
||
const nom = await fetchWithRetry(`https://nominatim.openstreetmap.org/search?${nomParams}`, {
|
||
attempts: 1,
|
||
timeoutMs: 9000,
|
||
fetchOptions: {
|
||
headers: {
|
||
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||
Accept: 'application/json',
|
||
},
|
||
},
|
||
});
|
||
|
||
const nomData = await nom.json();
|
||
const results = (nomData || []).map(item => {
|
||
const address = item.address || {};
|
||
return {
|
||
name: address.city || address.town || address.village || address.hamlet || address.county || item.display_name?.split(',')?.[0] || 'Unknown',
|
||
latitude: Number(item.lat),
|
||
longitude: Number(item.lon),
|
||
country: address.country || '',
|
||
admin1: address.state || address.region || address.county || '',
|
||
timezone: 'auto',
|
||
};
|
||
}).filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude));
|
||
|
||
return res.json({ results });
|
||
} catch (e) {
|
||
console.error('Geocoding error:', e.message);
|
||
res.status(500).json({ error: 'Geocoding failed' });
|
||
}
|
||
});
|
||
|
||
// Reverse geocoding proxy
|
||
app.get('/api/reverse-geocode', async (req, res) => {
|
||
const { lat, lon } = req.query;
|
||
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||
if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) {
|
||
return res.status(400).json({ error: 'Invalid lat/lon' });
|
||
}
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
format: 'jsonv2',
|
||
lat: String(lat),
|
||
lon: String(lon),
|
||
zoom: '10',
|
||
addressdetails: '1',
|
||
});
|
||
|
||
const r = await fetchWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||
attempts: 1,
|
||
timeoutMs: 9000,
|
||
fetchOptions: {
|
||
headers: {
|
||
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||
Accept: 'application/json',
|
||
},
|
||
},
|
||
});
|
||
|
||
const data = await r.json();
|
||
const address = data.address || {};
|
||
const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location';
|
||
|
||
res.json({
|
||
name,
|
||
admin1: address.state || address.region || address.county || '',
|
||
country: address.country || '',
|
||
latitude: Number(lat),
|
||
longitude: Number(lon),
|
||
timezone: 'auto',
|
||
display_name: data.display_name || '',
|
||
});
|
||
} catch (e) {
|
||
console.error('Reverse geocoding error:', e.message);
|
||
res.status(500).json({ error: 'Reverse geocoding failed' });
|
||
}
|
||
});
|
||
|
||
// Weather proxy
|
||
app.get('/api/weather', async (req, res) => {
|
||
const { lat, lon, tz, units } = req.query;
|
||
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||
if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) {
|
||
return res.status(400).json({ error: 'Invalid lat/lon' });
|
||
}
|
||
const timezone = tz || 'auto';
|
||
const useMetric = units === 'metric';
|
||
const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`;
|
||
try {
|
||
const params = new URLSearchParams({
|
||
latitude: lat, longitude: lon, timezone,
|
||
current: [
|
||
'temperature_2m','relative_humidity_2m','apparent_temperature',
|
||
'is_day','precipitation','weather_code','cloud_cover',
|
||
'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility'
|
||
].join(','),
|
||
hourly: [
|
||
'temperature_2m','precipitation_probability','weather_code',
|
||
'wind_speed_10m','apparent_temperature','rain','snowfall'
|
||
].join(','),
|
||
daily: [
|
||
'weather_code','temperature_2m_max','temperature_2m_min',
|
||
'precipitation_sum','precipitation_probability_max',
|
||
'wind_speed_10m_max','sunrise','sunset','uv_index_max'
|
||
].join(','),
|
||
minutely_15: 'precipitation',
|
||
forecast_minutely_15: 24,
|
||
forecast_days: 16,
|
||
wind_speed_unit: useMetric ? 'kmh' : 'mph',
|
||
temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
|
||
precipitation_unit: useMetric ? 'mm' : 'inch'
|
||
});
|
||
|
||
let r;
|
||
try {
|
||
// Single quick attempt with 15-minute rain data; if Open-Meteo rejects it, fall back to the classic request
|
||
r = await fetchWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`, { attempts: 1, timeoutMs: 12000 });
|
||
} catch (firstErr) {
|
||
params.delete('minutely_15'); params.delete('forecast_minutely_15');
|
||
r = await fetchWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`, { attempts: 3, timeoutMs: 12000, retryDelayMs: 500 });
|
||
}
|
||
const data = await r.json();
|
||
setCache(cacheKey, data);
|
||
return res.json(data);
|
||
} catch (e) {
|
||
const cached = weatherCache.get(cacheKey);
|
||
if (cached && Date.now() - cached.at < 45 * 60 * 1000) {
|
||
res.setHeader('X-Weather-Cache', 'stale-fallback');
|
||
console.warn('Weather proxy using stale cache for', cacheKey);
|
||
return res.json(cached.data);
|
||
}
|
||
console.error('Weather proxy failed:', e.message);
|
||
res.status(502).json({ error: 'Weather fetch failed' });
|
||
}
|
||
});
|
||
|
||
// Open-Meteo Air Quality proxy
|
||
app.get('/api/air-quality', async (req, res) => {
|
||
const { lat, lon, tz } = req.query;
|
||
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||
if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) {
|
||
return res.status(400).json({ error: 'Invalid lat/lon' });
|
||
}
|
||
try {
|
||
const params = new URLSearchParams({
|
||
latitude: lat,
|
||
longitude: lon,
|
||
timezone: tz || 'auto',
|
||
current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
|
||
forecast_days: 1,
|
||
});
|
||
const r = await fetchWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, { attempts: 2, timeoutMs: 12000, retryDelayMs: 500 });
|
||
const data = await r.json();
|
||
return res.json(data);
|
||
} catch (e) {
|
||
console.error('Air quality fetch error:', e);
|
||
res.setHeader('X-Air-Quality-Fallback', 'empty');
|
||
res.json({ current: {} });
|
||
}
|
||
});
|
||
|
||
// NWS alerts proxy (US only)
|
||
app.get('/api/alerts', async (req, res) => {
|
||
const { lat, lon } = req.query;
|
||
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||
if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) {
|
||
return res.status(400).json({ error: 'Invalid lat/lon' });
|
||
}
|
||
try {
|
||
const r = await fetchWithRetry(`${NWS_BASE}/alerts/active?point=${lat},${lon}`, {
|
||
attempts: 1,
|
||
timeoutMs: 12000,
|
||
fetchOptions: {
|
||
headers: {
|
||
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||
Accept: 'application/geo+json',
|
||
},
|
||
},
|
||
});
|
||
const data = await r.json();
|
||
res.json(data);
|
||
} catch (e) {
|
||
console.error('Alerts fetch error:', e.message);
|
||
res.setHeader('X-Alerts-Fallback', 'empty');
|
||
res.json({ features: [] });
|
||
}
|
||
});
|
||
|
||
// Radar tile configuration.
|
||
// Primary: RainViewer (global composite, 10-min frames, free, tiles load straight from their CDN).
|
||
// Fallback: IEM NEXRAD (US only). IEM blocks IPs that pull too many tiles and answers with a "sorry"
|
||
// redirect, so it must not be the only source. Satellite imagery is separate (NASA GIBS, client-side).
|
||
let rainviewerCache = { at: 0, data: null };
|
||
async function rainviewerFrames() {
|
||
if (rainviewerCache.data && Date.now() - rainviewerCache.at < 120000) return rainviewerCache.data;
|
||
const r = await fetchWithRetry('https://api.rainviewer.com/public/weather-maps.json', { attempts: 2, timeoutMs: 8000 });
|
||
if (!r.ok) throw new Error(`RainViewer ${r.status}`);
|
||
const j = await r.json();
|
||
const host = j.host || 'https://tilecache.rainviewer.com';
|
||
const frames = (j.radar?.past || []).slice(-12).map(f => ({
|
||
time: f.time,
|
||
url: `${host}${f.path}/256/{z}/{x}/{y}/2/1_1.png`, // colour scheme 2, smoothed, snow shown
|
||
isForecast: false,
|
||
maxNativeZoom: 7, // RainViewer's free tiles stop at z7; MapLibre overzooms beyond that
|
||
}));
|
||
if (!frames.length) throw new Error('RainViewer returned no frames');
|
||
rainviewerCache = { at: Date.now(), data: frames };
|
||
return frames;
|
||
}
|
||
|
||
app.get('/api/radar/times', async (req, res) => {
|
||
try {
|
||
try {
|
||
const past = await rainviewerFrames();
|
||
return res.json({ provider: 'rainviewer', radar: { past }, satellite: { infrared: [] } });
|
||
} catch (e) {
|
||
console.error('RainViewer unavailable, falling back to IEM:', e.message);
|
||
}
|
||
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const radarLayerBase = 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0';
|
||
const radarOffsetsMin = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0];
|
||
const radarPast = radarOffsetsMin.map(offset => {
|
||
const layer = offset === 0 ? 'nexrad-n0q-900913' : `nexrad-n0q-900913-m${String(offset).padStart(2, '0')}m`;
|
||
return {
|
||
time: now - (offset * 60),
|
||
url: `${radarLayerBase}/${layer}/{z}/{x}/{y}.png`,
|
||
isForecast: false,
|
||
maxNativeZoom: 12,
|
||
};
|
||
});
|
||
res.json({ provider: 'iem-radar', radar: { past: radarPast }, satellite: { infrared: [] } });
|
||
} catch (e) {
|
||
console.error('Radar config error:', e.message);
|
||
res.status(500).json({ error: 'Radar times failed' });
|
||
}
|
||
});
|
||
|
||
app.use(express.json({ limit: '10kb' }));
|
||
|
||
// ── VAPID public key ──────────────────────────────────────────────────────────
|
||
app.get('/api/vapid-public-key', (_req, res) => res.json({ publicKey: vapidKeys.publicKey }));
|
||
|
||
// ── Push subscription management ──────────────────────────────────────────────
|
||
app.post('/api/push-subscribe', (req, res) => {
|
||
const { subscription, lat, lon, ntfyTopic, thresholds, digestHour } = req.body;
|
||
if (!subscription?.endpoint) return res.status(400).json({ error: 'Missing subscription' });
|
||
if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) return res.status(400).json({ error: 'Invalid coords' });
|
||
const idx = pushSubscriptions.findIndex(s => s.subscription.endpoint === subscription.endpoint);
|
||
const entry = {
|
||
subscription, lat: Number(lat), lon: Number(lon),
|
||
ntfyTopic: ntfyTopic || '', thresholds: thresholds || {},
|
||
digestHour: digestHour ?? 7,
|
||
seenAlertIds: idx >= 0 ? pushSubscriptions[idx].seenAlertIds : [],
|
||
lastDigestDate: idx >= 0 ? pushSubscriptions[idx].lastDigestDate : '',
|
||
lastThresholdDate: '', lastThresholdAlerted: {},
|
||
};
|
||
if (idx >= 0) pushSubscriptions[idx] = entry; else pushSubscriptions.push(entry);
|
||
saveSubs();
|
||
res.status(201).json({ ok: true });
|
||
});
|
||
|
||
app.post('/api/push-unsubscribe', (req, res) => {
|
||
const { endpoint } = req.body || {};
|
||
pushSubscriptions = pushSubscriptions.filter(s => s.subscription.endpoint !== endpoint);
|
||
saveSubs();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ── Hurricane tracking proxy (NHC) ────────────────────────────────────────────
|
||
const hCache = { data: null, at: 0 };
|
||
const NHC_BASE = process.env.NHC_BASE || 'https://www.nhc.noaa.gov';
|
||
app.get('/api/hurricanes', async (_req, res) => {
|
||
if (hCache.data && Date.now() - hCache.at < 5 * 60 * 1000) return res.json(hCache.data);
|
||
try {
|
||
const r = await fetchWithRetry(`${NHC_BASE}/CurrentStorms.json`, {
|
||
attempts: 2, timeoutMs: 10000,
|
||
fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/json' } },
|
||
});
|
||
const data = await r.json();
|
||
hCache.data = { storms: data.activeStorms || [] };
|
||
hCache.at = Date.now();
|
||
res.json(hCache.data);
|
||
} catch (e) {
|
||
console.error('Hurricane fetch error:', e.message);
|
||
res.json(hCache.data || { storms: [] });
|
||
}
|
||
});
|
||
|
||
// Strength class for a point on the forecast track (falls back to the storm's own class when wind is unknown)
|
||
function typeForKt(kt, fallback) {
|
||
if (!kt) return fallback || 'TS';
|
||
return kt >= 64 ? 'HU' : kt >= 34 ? 'TS' : 'TD';
|
||
}
|
||
app.get('/api/hurricanes/track', async (req, res) => {
|
||
const { stormId } = req.query;
|
||
if (!stormId || !/^[a-z]{2}\d{6}$/i.test(stormId)) {
|
||
return res.json({ lin: null, pts: null, pgn: null });
|
||
}
|
||
try {
|
||
const sid = stormId.toLowerCase();
|
||
let storm = (hCache.data?.storms || []).find(s => s.id?.toLowerCase() === sid);
|
||
if (!storm) {
|
||
const r = await fetchWithRetry(`${NHC_BASE}/CurrentStorms.json`, {
|
||
attempts: 2, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)' } }
|
||
});
|
||
storm = ((await r.json()).activeStorms || []).find(s => s.id?.toLowerCase() === sid);
|
||
}
|
||
const advisoryUrl = storm?.forecastAdvisory?.url;
|
||
if (!advisoryUrl) return res.json({ lin: null, pts: null, pgn: null });
|
||
|
||
const r = await fetchWithRetry(advisoryUrl, {
|
||
attempts: 2, timeoutMs: 8000,
|
||
fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)' } }
|
||
});
|
||
const text = (await r.text()).replace(/<[^>]+>/g, '');
|
||
|
||
const issuanceMs = new Date(storm?.forecastAdvisory?.issuance || storm?.lastUpdate || Date.now()).getTime();
|
||
const issueDate = new Date(issuanceMs);
|
||
|
||
function parseDDHHZ(ddHH) {
|
||
const day = parseInt(ddHH.slice(0, 2));
|
||
const hour = parseInt(ddHH.slice(2, 4));
|
||
const d = new Date(issueDate);
|
||
d.setUTCDate(day);
|
||
d.setUTCHours(hour, 0, 0, 0);
|
||
if (d < issueDate) d.setUTCMonth(d.getUTCMonth() + 1);
|
||
return Math.round((d - issueDate) / 3600000);
|
||
}
|
||
|
||
const positions = [];
|
||
|
||
// Current position from "CENTER LOCATED NEAR LL.LN LLL.LW AT DD/HHMMZ"
|
||
const initM = text.match(/CENTER LOCATED NEAR\s+(\d+\.?\d*)([NS])\s+(\d+\.?\d*)([EW])/);
|
||
const curWindM = text.match(/MAX SUSTAINED WINDS\s+(\d+)\s+KT/);
|
||
if (initM) {
|
||
positions.push({
|
||
tau: 0,
|
||
lat: parseFloat(initM[1]) * (initM[2] === 'S' ? -1 : 1),
|
||
lon: parseFloat(initM[3]) * (initM[4] === 'W' ? -1 : 1),
|
||
windKt: curWindM ? parseInt(curWindM[1]) : 0,
|
||
type: typeForKt(curWindM ? parseInt(curWindM[1]) : 0, storm?.classification),
|
||
});
|
||
}
|
||
|
||
// "FORECAST VALID DD/HHMMZ LL.LN LLL.LW" and "OUTLOOK VALID ..." lines
|
||
const fcstRe = /(?:FORECAST|OUTLOOK)\s+VALID\s+(\d{2})\/(\d{2})\d{2}Z\s+(\d+\.?\d*)([NS])\s+(\d+\.?\d*)([EW])/g;
|
||
let m;
|
||
while ((m = fcstRe.exec(text)) !== null) {
|
||
const tau = parseDDHHZ(m[1] + m[2]);
|
||
const lat = parseFloat(m[3]) * (m[4] === 'S' ? -1 : 1);
|
||
const lon = parseFloat(m[5]) * (m[6] === 'W' ? -1 : 1);
|
||
const windM = text.substring(m.index).match(/MAX WIND\s+(\d+)\s+KT/);
|
||
const kt = windM ? parseInt(windM[1]) : 0;
|
||
positions.push({ tau, lat, lon, windKt: kt, type: typeForKt(kt, storm?.classification) });
|
||
}
|
||
|
||
if (!positions.length) return res.json({ lin: null, pts: null, pgn: null });
|
||
|
||
const coords = positions.map(p => [p.lon, p.lat]);
|
||
const ptFeatures = positions.map(p => ({
|
||
type: 'Feature',
|
||
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
||
properties: { TAU: p.tau, MAXWIND: p.windKt, TCDVLP: p.type },
|
||
}));
|
||
|
||
res.json({
|
||
lin: { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} }] },
|
||
pts: { type: 'FeatureCollection', features: ptFeatures },
|
||
pgn: null,
|
||
});
|
||
} catch (e) {
|
||
console.error('[track] error:', e.message);
|
||
res.json({ lin: null, pts: null, pgn: null });
|
||
}
|
||
});
|
||
|
||
// ── Wildfire perimeter proxy (NIFC) ───────────────────────────────────────────
|
||
const wfCache = { data: null, at: 0 };
|
||
app.get('/api/wildfires', async (_req, res) => {
|
||
if (wfCache.data && Date.now() - wfCache.at < 15 * 60 * 1000) return res.json(wfCache.data);
|
||
try {
|
||
const params = new URLSearchParams({
|
||
where: 'GISAcres>100', outFields: 'IncidentName,GISAcres,PercentContained',
|
||
f: 'geojson', resultRecordCount: '150', geometryPrecision: '4', outSR: '4326',
|
||
});
|
||
const r = await fetchWithRetry(
|
||
`https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query?${params}`,
|
||
{ attempts: 2, timeoutMs: 15000 }
|
||
);
|
||
wfCache.data = await r.json();
|
||
wfCache.at = Date.now();
|
||
res.json(wfCache.data);
|
||
} catch (e) {
|
||
console.error('Wildfire fetch error:', e.message);
|
||
res.json(wfCache.data || { type: 'FeatureCollection', features: [] });
|
||
}
|
||
});
|
||
|
||
// ── SPC storm reports proxy ────────────────────────────────────────────────────
|
||
const spcCache = { data: null, at: 0 };
|
||
function parseSPCCsv(csv, type) {
|
||
return csv.trim().split('\n').slice(1).flatMap(line => {
|
||
const p = line.split(',');
|
||
const lat = parseFloat(p[5]), lon = parseFloat(p[6]);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90) return [];
|
||
return [{
|
||
type: 'Feature',
|
||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||
properties: { type, location: p[4] || '', comments: p.slice(7).join(',').slice(0, 120) },
|
||
}];
|
||
});
|
||
}
|
||
app.get('/api/spc/reports', async (_req, res) => {
|
||
if (spcCache.data && Date.now() - spcCache.at < 10 * 60 * 1000) return res.json(spcCache.data);
|
||
try {
|
||
const results = await Promise.allSettled(['torn', 'wind', 'hail'].map(t =>
|
||
fetchWithRetry(`https://www.spc.noaa.gov/climo/reports/today_filtered_${t}.csv`, {
|
||
attempts: 1, timeoutMs: 8000,
|
||
fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)' } },
|
||
}).then(r => r.text()).then(csv => parseSPCCsv(csv, t))
|
||
));
|
||
spcCache.data = { type: 'FeatureCollection', features: results.flatMap(r => r.status === 'fulfilled' ? r.value : []) };
|
||
spcCache.at = Date.now();
|
||
res.json(spcCache.data);
|
||
} catch (e) {
|
||
console.error('SPC fetch error:', e.message);
|
||
res.json(spcCache.data || { type: 'FeatureCollection', features: [] });
|
||
}
|
||
});
|
||
|
||
// ── Ambient Weather personal station ─────────────────────────────────────────
|
||
const AMBIENT_CONFIG_FILE = path.join(DATA_DIR, 'ambient-config.json');
|
||
let ambientServerConfig = { apiKey: '', appKey: '' };
|
||
try {
|
||
if (fs.existsSync(AMBIENT_CONFIG_FILE)) {
|
||
ambientServerConfig = JSON.parse(fs.readFileSync(AMBIENT_CONFIG_FILE, 'utf8'));
|
||
}
|
||
} catch {}
|
||
|
||
function saveAmbientConfig() {
|
||
try { fs.writeFileSync(AMBIENT_CONFIG_FILE, JSON.stringify(ambientServerConfig)); } catch {}
|
||
}
|
||
|
||
const ambientCaches = new Map();
|
||
|
||
// Save keys server-side so all devices share them
|
||
app.post('/api/ambient-config', requireDashboardAuth, requireAdmin, (req, res) => {
|
||
const { apiKey, appKey } = req.body || {};
|
||
if (!apiKey || !appKey) return res.status(400).json({ error: 'apiKey and appKey required' });
|
||
ambientServerConfig = { apiKey, appKey };
|
||
saveAmbientConfig();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// Return whether server-side keys exist (never expose the actual keys)
|
||
app.get('/api/ambient-config', (_req, res) => {
|
||
const hasKeys = !!(ambientServerConfig.apiKey && ambientServerConfig.appKey);
|
||
res.json({ configured: hasKeys, stationName: dashAuth?.stationName || null });
|
||
});
|
||
|
||
app.get('/api/ambient', async (req, res) => {
|
||
// Priority: query params (client override) → server-saved → env vars
|
||
const apiKey = req.query.apiKey || ambientServerConfig.apiKey || process.env.AMBIENT_API_KEY;
|
||
const appKey = req.query.appKey || ambientServerConfig.appKey || process.env.AMBIENT_APP_KEY;
|
||
if (!apiKey || !appKey) return res.status(503).json({ error: 'Ambient Weather keys not configured' });
|
||
|
||
const cacheKey = apiKey;
|
||
const cached = ambientCaches.get(cacheKey);
|
||
if (cached && Date.now() - cached.at < 60 * 1000) return res.json(cached.data);
|
||
|
||
try {
|
||
const url = `https://rt.ambientweather.net/v1/devices?apiKey=${encodeURIComponent(apiKey)}&applicationKey=${encodeURIComponent(appKey)}`;
|
||
const r = await fetchWithRetry(url, { attempts: 2, timeoutMs: 10000, retryDelayMs: 500 });
|
||
const devices = await r.json();
|
||
ambientCaches.set(cacheKey, { data: devices, at: Date.now() });
|
||
res.json(devices);
|
||
} catch (e) {
|
||
console.error('Ambient Weather fetch error:', e.message);
|
||
if (cached) return res.json(cached.data);
|
||
res.status(502).json({ error: 'Ambient Weather fetch failed' });
|
||
}
|
||
});
|
||
|
||
// ── Personal weather station proxy ────────────────────────────────────────────
|
||
app.get('/api/pws', async (req, res) => {
|
||
const { url } = req.query;
|
||
if (!url) return res.status(400).json({ error: 'Missing url' });
|
||
let parsed;
|
||
try { parsed = new URL(url); } catch { return res.status(400).json({ error: 'Invalid url' }); }
|
||
if (!['http:', 'https:'].includes(parsed.protocol)) return res.status(400).json({ error: 'Only http/https allowed' });
|
||
try {
|
||
const r = await fetchWithRetry(url, { attempts: 1, timeoutMs: 5000 });
|
||
res.json(await r.json());
|
||
} catch (e) {
|
||
res.status(502).json({ error: 'PWS fetch failed' });
|
||
}
|
||
});
|
||
|
||
// ── NWS Forecast Discussion ───────────────────────────────────────────────────
|
||
const nwsDiscCache = new Map();
|
||
app.get('/api/nws/discussion', async (req, res) => {
|
||
const { lat, lon } = req.query;
|
||
if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) {
|
||
return res.status(400).json({ error: 'Invalid coords' });
|
||
}
|
||
const cacheKey = `${parseFloat(lat).toFixed(2)},${parseFloat(lon).toFixed(2)}`;
|
||
const cached = nwsDiscCache.get(cacheKey);
|
||
if (cached && Date.now() - cached.at < 45 * 60 * 1000) return res.json(cached.data);
|
||
|
||
const NWS_HEADERS = { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' };
|
||
try {
|
||
// Step 1: resolve lat/lon to NWS forecast office
|
||
const ptRes = await fetchWithRetry(
|
||
`https://api.weather.gov/points/${parseFloat(lat).toFixed(4)},${parseFloat(lon).toFixed(4)}`,
|
||
{ attempts: 2, timeoutMs: 10000, fetchOptions: { headers: NWS_HEADERS } }
|
||
);
|
||
if (!ptRes.ok) return res.status(404).json({ error: 'Location not covered by NWS (US only)' });
|
||
const ptData = await ptRes.json();
|
||
const cwa = ptData.properties?.cwa;
|
||
if (!cwa) return res.status(404).json({ error: 'No NWS office found for this location' });
|
||
|
||
// Step 2: get list of Area Forecast Discussions for that office
|
||
const listRes = await fetchWithRetry(
|
||
`https://api.weather.gov/products/types/AFD/locations/${cwa}`,
|
||
{ attempts: 2, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': NWS_HEADERS['User-Agent'] } } }
|
||
);
|
||
const listData = await listRes.json();
|
||
const latest = listData['@graph']?.[0];
|
||
if (!latest) return res.status(404).json({ error: 'No forecast discussion available' });
|
||
|
||
// Step 3: fetch the actual product text
|
||
const prodUrl = latest['@id'];
|
||
const prodRes = await fetchWithRetry(prodUrl, {
|
||
attempts: 2, timeoutMs: 10000,
|
||
fetchOptions: { headers: { 'User-Agent': NWS_HEADERS['User-Agent'] } }
|
||
});
|
||
const prodData = await prodRes.json();
|
||
|
||
const result = {
|
||
office: cwa,
|
||
issuingOffice: prodData.issuingOffice || cwa,
|
||
issuanceTime: prodData.issuanceTime,
|
||
text: prodData.productText || '',
|
||
};
|
||
nwsDiscCache.set(cacheKey, { data: result, at: Date.now() });
|
||
res.json(result);
|
||
} catch (e) {
|
||
console.error('[discussion]', e.message);
|
||
const stale = nwsDiscCache.get(cacheKey);
|
||
if (stale) return res.json(stale.data);
|
||
res.status(502).json({ error: 'Could not fetch forecast discussion' });
|
||
}
|
||
});
|
||
|
||
// ── Alert watcher + daily digest + threshold cron ─────────────────────────────
|
||
const WMO_SHORT = { 0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',51:'Light drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',80:'Showers',95:'Thunderstorm' };
|
||
function wmoShort(c) { return WMO_SHORT[c] || 'Variable'; }
|
||
|
||
async function doWebPush(sub, payload) {
|
||
try {
|
||
await webpush.sendNotification(sub.subscription, JSON.stringify(payload));
|
||
} catch (err) {
|
||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||
pushSubscriptions = pushSubscriptions.filter(s => s.subscription.endpoint !== sub.subscription.endpoint);
|
||
saveSubs();
|
||
}
|
||
}
|
||
}
|
||
|
||
async function doNtfy(topic, title, body, priority = 'default') {
|
||
if (!topic) return;
|
||
try {
|
||
await fetch(topic, { method: 'POST', body, headers: { Title: title, Priority: priority, Tags: 'weather' } });
|
||
} catch {}
|
||
}
|
||
|
||
async function alertWatcherCron() {
|
||
const now = new Date();
|
||
const todayStr = now.toDateString();
|
||
const currentHour = now.getHours();
|
||
const base = `http://localhost:${PORT}`;
|
||
|
||
// Fetch station once per cron run — reuses the server-side cache
|
||
let stationObs = null;
|
||
if (ambientServerConfig.apiKey && ambientServerConfig.appKey) {
|
||
try {
|
||
const qs = `?apiKey=${encodeURIComponent(ambientServerConfig.apiKey)}&appKey=${encodeURIComponent(ambientServerConfig.appKey)}`;
|
||
const sr = await fetch(`${base}/api/ambient${qs}`);
|
||
if (sr.ok) {
|
||
const devices = await sr.json();
|
||
const d = devices?.[0]?.lastData;
|
||
if (d?.dateutc && Date.now() - d.dateutc < 15 * 60 * 1000) {
|
||
stationObs = d;
|
||
recordStationReading(d);
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// ── Log NWS alerts for the dashboard location regardless of subscribers ───────
|
||
const dashLat = dashAuth?.stationLat;
|
||
const dashLon = dashAuth?.stationLon;
|
||
const nwsRes = nwsSaved().resolved;
|
||
const alertSpots = [];
|
||
if (dashLat != null) alertSpots.push({ lat: dashLat, lon: dashLon });
|
||
if (nwsRes && !(nwsRes.lat === dashLat && nwsRes.lon === dashLon)) alertSpots.push({ lat: nwsRes.lat, lon: nwsRes.lon, zip: nwsRes.zip });
|
||
for (const spot of alertSpots) {
|
||
const dashAlertData = await fetch(`${base}/api/alerts?lat=${spot.lat}&lon=${spot.lon}`)
|
||
.then(r => r.json()).catch(() => ({ features: [] }));
|
||
for (const a of dashAlertData.features || []) {
|
||
const alertId = a.id || a.properties?.id;
|
||
if (alertId && !loggedAlertIds.has(alertId)) {
|
||
loggedAlertIds.add(alertId);
|
||
const p = a.properties || {};
|
||
recordAlertLog({ type: 'nws', alertId, ...nwsLogFields(p), lat: spot.lat, lon: spot.lon, ...(spot.zip ? { zip: spot.zip } : {}) });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!pushSubscriptions.length) return;
|
||
|
||
for (const sub of [...pushSubscriptions]) {
|
||
try {
|
||
// ── NWS alerts ──────────────────────────────────────────────────────────
|
||
const alertData = await fetch(`${base}/api/alerts?lat=${sub.lat}&lon=${sub.lon}`)
|
||
.then(r => r.json()).catch(() => ({ features: [] }));
|
||
const newAlerts = (alertData.features || []).filter(f => {
|
||
const id = f.id || f.properties?.id;
|
||
return id && !sub.seenAlertIds.includes(id);
|
||
});
|
||
if (newAlerts.length) {
|
||
for (const a of newAlerts) {
|
||
const p = a.properties || {};
|
||
const title = `Weather alert: ${p.event || 'Severe weather'}`;
|
||
const body = p.headline || p.description || 'Alert for your saved location.';
|
||
await doWebPush(sub, { title, body, tag: 'weather-alert', url: '/?tab=alerts' });
|
||
await doNtfy(sub.ntfyTopic, title, body, 'high');
|
||
const alertId = a.id || p.id;
|
||
if (alertId && !loggedAlertIds.has(alertId)) {
|
||
loggedAlertIds.add(alertId);
|
||
recordAlertLog({ type: 'nws', alertId, ...nwsLogFields(p), lat: sub.lat, lon: sub.lon });
|
||
}
|
||
}
|
||
sub.seenAlertIds = [...new Set([...sub.seenAlertIds, ...newAlerts.map(f => f.id || f.properties?.id)])].slice(-200);
|
||
saveSubs();
|
||
}
|
||
|
||
// ── Daily digest ─────────────────────────────────────────────────────────
|
||
if (sub.lastDigestDate !== todayStr && currentHour === (sub.digestHour ?? 7)) {
|
||
const wx = await fetch(`${base}/api/weather?lat=${sub.lat}&lon=${sub.lon}`).then(r => r.json()).catch(() => null);
|
||
if (wx) {
|
||
const d = wx.daily;
|
||
const body = `High ${Math.round(d.temperature_2m_max[0])}° / Low ${Math.round(d.temperature_2m_min[0])}° · ${wmoShort(d.weather_code[0])} · Rain ${d.precipitation_probability_max[0]}% · UV ${Math.round(d.uv_index_max[0] ?? 0)}`;
|
||
await doWebPush(sub, { title: '☀️ NeCloud Daily Forecast', body, tag: 'daily-digest', url: '/' });
|
||
await doNtfy(sub.ntfyTopic, '☀️ NeCloud Daily Forecast', body);
|
||
sub.lastDigestDate = todayStr;
|
||
saveSubs();
|
||
}
|
||
}
|
||
|
||
// ── Custom thresholds ─────────────────────────────────────────────────────
|
||
if (sub.thresholds && Object.keys(sub.thresholds).length) {
|
||
if (sub.lastThresholdDate !== todayStr) { sub.lastThresholdAlerted = {}; sub.lastThresholdDate = todayStr; }
|
||
const wx = await fetch(`${base}/api/weather?lat=${sub.lat}&lon=${sub.lon}`).then(r => r.json()).catch(() => null);
|
||
if (wx) {
|
||
const c = wx.current;
|
||
const alerted = sub.lastThresholdAlerted || {};
|
||
|
||
const gustVal = stationObs?.windgustmph ?? c.wind_gusts_10m;
|
||
const gustSrc = stationObs ? 'station' : 'forecast model';
|
||
if (sub.thresholds.wind && gustVal > sub.thresholds.wind && !alerted.wind) {
|
||
alerted.wind = true;
|
||
const body = `Gusts at ${Math.round(gustVal)} mph — your threshold is ${sub.thresholds.wind} mph (${gustSrc})`;
|
||
await doWebPush(sub, { title: '💨 Wind Alert', body, tag: 'wind-threshold', url: '/' });
|
||
await doNtfy(sub.ntfyTopic, '💨 Wind Alert', body, 'high');
|
||
recordAlertLog({ type: 'threshold', tag: 'wind-threshold', value: gustVal, threshold: sub.thresholds.wind, body, lat: sub.lat, lon: sub.lon });
|
||
}
|
||
|
||
const tempVal = stationObs?.tempf ?? c.temperature_2m;
|
||
const tempSrc = stationObs ? 'station' : 'forecast model';
|
||
if (sub.thresholds.tempLow && tempVal < sub.thresholds.tempLow && !alerted.tempLow) {
|
||
alerted.tempLow = true;
|
||
const body = `Temperature at ${Math.round(tempVal)}°F — at or below your freeze threshold (${tempSrc})`;
|
||
await doWebPush(sub, { title: '🧊 Freeze Alert', body, tag: 'temp-threshold', url: '/' });
|
||
await doNtfy(sub.ntfyTopic, '🧊 Freeze Alert', body);
|
||
recordAlertLog({ type: 'threshold', tag: 'temp-threshold', value: tempVal, threshold: sub.thresholds.tempLow, body, lat: sub.lat, lon: sub.lon });
|
||
}
|
||
if (sub.thresholds.aqi) {
|
||
const aqData = await fetch(`${base}/api/air-quality?lat=${sub.lat}&lon=${sub.lon}`).then(r => r.json()).catch(() => null);
|
||
const aqi = aqData?.current?.us_aqi;
|
||
if (aqi && aqi > sub.thresholds.aqi && !alerted.aqi) {
|
||
alerted.aqi = true;
|
||
const body = `AQI at ${aqi} — above your threshold of ${sub.thresholds.aqi}`;
|
||
await doWebPush(sub, { title: '😷 Air Quality Alert', body, tag: 'aqi-threshold', url: '/' });
|
||
await doNtfy(sub.ntfyTopic, '😷 Air Quality Alert', body);
|
||
recordAlertLog({ type: 'threshold', tag: 'aqi-threshold', value: aqi, threshold: sub.thresholds.aqi, body, lat: sub.lat, lon: sub.lon });
|
||
}
|
||
}
|
||
// ── 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 — 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');
|
||
recordAlertLog({ type: 'threshold', tag: 'rain-threshold', value: stationObs.rainratein, threshold: sub.thresholds.rainRate, body, lat: sub.lat, lon: sub.lon });
|
||
}
|
||
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');
|
||
recordAlertLog({ type: 'threshold', tag: 'temp-high', value: stationObs.tempf, threshold: sub.thresholds.tempHigh, body, lat: sub.lat, lon: sub.lon });
|
||
}
|
||
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');
|
||
recordAlertLog({ type: 'threshold', tag: 'temp-low', value: stationObs.tempf, threshold: sub.thresholds.tempLow, body, lat: sub.lat, lon: sub.lon });
|
||
}
|
||
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');
|
||
recordAlertLog({ type: 'threshold', tag: 'freeze', value: stationObs.tempf, threshold: 34, body, lat: sub.lat, lon: sub.lon });
|
||
}
|
||
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');
|
||
recordAlertLog({ type: 'threshold', tag: 'wind-station', value: stationObs.windgustmph, threshold: sub.thresholds.wind, body, lat: sub.lat, lon: sub.lon });
|
||
}
|
||
}
|
||
// ── 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');
|
||
recordAlertLog({ type: 'threshold', tag: 'station-offline', body, lat: sub.lat, lon: sub.lon });
|
||
}
|
||
} else if (stationObs) {
|
||
alerted.stationOffline = false;
|
||
}
|
||
|
||
sub.lastThresholdAlerted = alerted;
|
||
saveSubs();
|
||
}
|
||
}
|
||
await new Promise(r => setTimeout(r, 200));
|
||
} catch (err) {
|
||
console.error('[cron] Subscription error:', err.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
setTimeout(alertWatcherCron, 15000);
|
||
setInterval(alertWatcherCron, 60 * 1000);
|
||
|
||
// ── Dashboard routes ──────────────────────────────────────────────────────────
|
||
app.use('/dashboard', express.urlencoded({ extended: false }));
|
||
|
||
// Setup (first-time account creation)
|
||
app.get('/dashboard/setup', (req, res) => {
|
||
if (hasUsers()) return res.redirect('/dashboard');
|
||
sendHtml(res, 'dashboard-setup.html');
|
||
});
|
||
|
||
// First-run setup creates the initial admin account
|
||
app.post('/dashboard/setup', (req, res) => {
|
||
if (hasUsers()) return res.redirect('/dashboard');
|
||
const { username, password } = req.body || {};
|
||
if (!USERNAME_RE.test(String(username || '')) || !password || password.length < PASSWORD_MIN) {
|
||
return res.redirect('/dashboard/setup?error=1');
|
||
}
|
||
const user = { username, role: 'admin', epoch: 0, createdAt: Date.now(), lastLogin: Date.now() };
|
||
setPassword(user, password); user.epoch = 0;
|
||
usersStore.users.push(user);
|
||
saveUsers();
|
||
dashAuth = dashAuth || {};
|
||
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
|
||
res.redirect('/dashboard');
|
||
});
|
||
|
||
// Login
|
||
app.get('/dashboard/login', (req, res) => {
|
||
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
||
if (currentUser(req)) return res.redirect('/dashboard');
|
||
sendHtml(res, 'dashboard-login.html');
|
||
});
|
||
|
||
app.post('/dashboard/login', (req, res) => {
|
||
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
||
const { username, password } = req.body || {};
|
||
if (!username || !password) return res.redirect('/dashboard/login?error=1');
|
||
const key = attemptKey(req, username);
|
||
if (isLockedOut(key)) return res.redirect('/dashboard/login?error=2');
|
||
const user = findUser(username);
|
||
const ok = checkPassword(user, password) && !user.disabled;
|
||
if (!ok) { noteLoginFailure(key); return res.redirect('/dashboard/login?error=1'); }
|
||
loginAttempts.delete(key);
|
||
user.lastLogin = Date.now();
|
||
saveUsers();
|
||
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch || 0)));
|
||
res.redirect('/dashboard');
|
||
});
|
||
|
||
// Logout
|
||
app.get('/dashboard/logout', (_req, res) => {
|
||
res.setHeader('Set-Cookie', 'dsession=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||
res.redirect('/dashboard/login');
|
||
});
|
||
|
||
// Password recovery: the daily token (printed in the server log) resets the primary admin account
|
||
app.get('/dashboard/recover', (_req, res) => {
|
||
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
||
sendHtml(res, 'dashboard-recover.html');
|
||
});
|
||
|
||
app.post('/dashboard/recover', (req, res) => {
|
||
if (!hasUsers()) return res.redirect('/dashboard/setup');
|
||
const { token, password } = req.body || {};
|
||
if (!token || !password || password.length < PASSWORD_MIN) return res.redirect('/dashboard/recover?error=1');
|
||
if (isLockedOut('recover|' + req.ip)) return res.redirect('/dashboard/recover?error=1');
|
||
const expected = getDailyRecoveryToken();
|
||
let match = false;
|
||
try { match = crypto.timingSafeEqual(Buffer.from(String(token).trim()), Buffer.from(expected)); } catch {}
|
||
if (!match) { noteLoginFailure('recover|' + req.ip); return res.redirect('/dashboard/recover?error=1'); }
|
||
const admin = activeAdmins()[0] || usersStore.users.find(u => u.role === 'admin') || usersStore.users[0];
|
||
admin.role = 'admin'; admin.disabled = false;
|
||
setPassword(admin, password);
|
||
clearAttemptsFor(admin.username);
|
||
saveUsers();
|
||
console.log(`[auth] recovery reset password for admin "${admin.username}"`);
|
||
res.redirect('/dashboard/login?recovered=1');
|
||
});
|
||
|
||
// Main dashboard
|
||
app.get('/dashboard', requireDashboardAuth, (_req, res) => {
|
||
sendHtml(res, 'dashboard.html');
|
||
});
|
||
|
||
// Settings page
|
||
// Account page: everyone can change their own password; admins also manage users here
|
||
app.get('/dashboard/account', requireDashboardAuth, (_req, res) => {
|
||
sendHtml(res, 'dashboard-account.html');
|
||
});
|
||
app.get('/dashboard/settings', requireDashboardAuth, (req, res) => {
|
||
if (req.dashUser.role !== 'admin') {
|
||
const esc = (t) => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
return res.status(403).send(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><body style="font-family:system-ui;max-width:32rem;margin:3rem auto;padding:0 1rem"><h2>Settings are admin-only</h2><p>You are signed in as <b>${esc(req.dashUser.username)}</b> (role: <b>${esc(req.dashUser.role || 'none')}</b>).</p><p>Sign out and sign in with your admin account, or ask an admin to change your role on the Account page.</p><p><a href="/dashboard/account">Account</a> · <a href="/dashboard/logout">Sign out</a> · <a href="/dashboard">Dashboard</a></p>`);
|
||
}
|
||
sendHtml(res, 'dashboard-settings.html');
|
||
});
|
||
|
||
// Downsample into ~`points` time buckets: averages for smooth series, true max/min for
|
||
// extremes, and a speed-weighted vector mean for wind direction.
|
||
const BUCKET_AVG = ['tempf', 'feelsLike', 'humidity', 'dewPoint', 'windspeedmph', 'baromrelin', 'baromabsin', 'uv', 'solarradiation', 'tempinf', 'humidityin'];
|
||
const BUCKET_MAX = ['windgustmph', 'rainratein', 'dailyrainin', 'maxdailygust'];
|
||
function bucketReadings(readings, points) {
|
||
const t0 = readings[0].dateutc, span = readings[readings.length - 1].dateutc - t0 || 1;
|
||
const size = span / points;
|
||
const buckets = new Map();
|
||
for (const r of readings) {
|
||
const i = Math.min(points - 1, Math.floor((r.dateutc - t0) / size));
|
||
let b = buckets.get(i);
|
||
if (!b) { b = { n: 0, tsum: 0, sums: {}, cnt: {}, max: {}, tmin: Infinity, tmax: -Infinity, vx: 0, vy: 0 }; buckets.set(i, b); }
|
||
b.n++; b.tsum += r.dateutc;
|
||
for (const k of BUCKET_AVG) if (r[k] != null) { b.sums[k] = (b.sums[k] || 0) + r[k]; b.cnt[k] = (b.cnt[k] || 0) + 1; }
|
||
for (const k of BUCKET_MAX) if (r[k] != null && (b.max[k] == null || r[k] > b.max[k])) b.max[k] = r[k];
|
||
if (r.tempf != null) { if (r.tempf < b.tmin) b.tmin = r.tempf; if (r.tempf > b.tmax) b.tmax = r.tempf; }
|
||
if (r.winddir != null) { const w = Math.max(r.windspeedmph || 0, 0.1) * Math.PI / 180; const a = r.winddir * Math.PI / 180; b.vx += Math.sin(a) * (r.windspeedmph || 0.1); b.vy += Math.cos(a) * (r.windspeedmph || 0.1); }
|
||
}
|
||
return [...buckets.entries()].sort(([a], [b]) => a - b).map(([, b]) => {
|
||
const o = { dateutc: Math.round(b.tsum / b.n), n: b.n };
|
||
for (const k of BUCKET_AVG) if (b.cnt[k]) o[k] = +(b.sums[k] / b.cnt[k]).toFixed(k === 'baromrelin' || k === 'baromabsin' ? 3 : 2);
|
||
for (const k of BUCKET_MAX) if (b.max[k] != null) o[k] = b.max[k];
|
||
if (b.tmin !== Infinity) { o.tempMin = b.tmin; o.tempMax = b.tmax; }
|
||
if (b.vx || b.vy) o.winddir = Math.round((Math.atan2(b.vx, b.vy) * 180 / Math.PI + 360) % 360);
|
||
return o;
|
||
});
|
||
}
|
||
|
||
// ── NWS reference station (official observations for a ZIP code) ──────────────
|
||
// Resolves the ZIP to its nearest NWS observation station, logs every observation it publishes
|
||
// (normalised to imperial units + the raw METAR), keeps hourly-forecast snapshots, and serves the
|
||
// data for the dashboard's overlay. Base URLs are overridable so it can be tested offline.
|
||
const NWS_BASE = process.env.NWS_BASE || 'https://api.weather.gov';
|
||
const ZIP_API = process.env.ZIP_API || 'https://api.zippopotam.us/us';
|
||
const NWS_DIR = path.join(DATA_DIR, 'nws');
|
||
const NWS_FC_DIR = path.join(NWS_DIR, 'forecast');
|
||
fs.mkdirSync(NWS_FC_DIR, { recursive: true });
|
||
const NWS_HEADERS = { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } };
|
||
const NWS_ZIP_FALLBACK = { '24550': { lat: 37.22, lon: -79.34, place: 'Evington, VA' } }; // used only if the ZIP lookup is unreachable
|
||
const nwsState = { running: false, lastError: null, lastPollAt: 0, lastForecastAt: 0, lastObsTs: 0, obsCount: 0, firstObsTs: null };
|
||
|
||
const nwsSaved = () => dashAuth?.nws || {};
|
||
function nwsSave(patch) {
|
||
dashAuth = { ...(dashAuth || {}), nws: { ...nwsSaved(), ...patch } };
|
||
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
|
||
}
|
||
const haversineKm = (a, b, c, d) => { const R = 6371, r = Math.PI / 180, dLat = (c - a) * r, dLon = (d - b) * r; const x = Math.sin(dLat / 2) ** 2 + Math.cos(a * r) * Math.cos(c * r) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(x)); };
|
||
|
||
async function nwsResolve(force = false) {
|
||
const cfg = nwsSaved();
|
||
const zip = cfg.zip || '24550';
|
||
const res = cfg.resolved;
|
||
if (!force && res && res.zip === zip && Date.now() - res.resolvedAt < 30 * 86400000) return res;
|
||
let lat = cfg.lat, lon = cfg.lon, place = null, approx = false;
|
||
if (lat == null || lon == null) {
|
||
try {
|
||
const r = await fetchWithRetry(`${ZIP_API}/${zip}`, { attempts: 2, timeoutMs: 10000 });
|
||
const z = await r.json();
|
||
const pl = z.places?.[0];
|
||
lat = parseFloat(pl.latitude); lon = parseFloat(pl.longitude);
|
||
place = `${pl['place name']}, ${pl['state abbreviation']}`;
|
||
} catch (e) {
|
||
const fb = NWS_ZIP_FALLBACK[zip];
|
||
if (!fb) throw new Error(`Could not look up ZIP ${zip}`);
|
||
lat = fb.lat; lon = fb.lon; place = fb.place; approx = true;
|
||
}
|
||
}
|
||
const pr = await fetchWithRetry(`${NWS_BASE}/points/${lat.toFixed(4)},${lon.toFixed(4)}`, { attempts: 2, timeoutMs: 12000, fetchOptions: NWS_HEADERS });
|
||
const pt = (await pr.json()).properties;
|
||
const sr = await fetchWithRetry(pt.observationStations, { attempts: 2, timeoutMs: 12000, fetchOptions: NWS_HEADERS });
|
||
const st = (await sr.json()).features?.[0];
|
||
if (!st) throw new Error('No observation station near this ZIP');
|
||
const [sLon, sLat] = st.geometry?.coordinates || [null, null];
|
||
const rl = pt.relativeLocation?.properties;
|
||
const resolved = {
|
||
zip, lat, lon, approx, place: place || (rl ? `${rl.city}, ${rl.state}` : zip),
|
||
stationId: st.properties.stationIdentifier, stationName: st.properties.name, stationLat: sLat, stationLon: sLon,
|
||
distanceMi: sLat != null ? +(haversineKm(lat, lon, sLat, sLon) * 0.621371).toFixed(1) : null,
|
||
forecastHourly: pt.forecastHourly, gridId: pt.gridId, timeZone: pt.timeZone, resolvedAt: Date.now(),
|
||
};
|
||
nwsSave({ zip, resolved });
|
||
console.log(`[nws] ZIP ${zip} → ${resolved.stationId} (${resolved.stationName}), ${resolved.distanceMi} mi`);
|
||
return resolved;
|
||
}
|
||
|
||
const nwsQ = (q, kind) => {
|
||
if (!q || q.value == null || !Number.isFinite(q.value)) return null;
|
||
const unit = String(q.unitCode || '').split(':').pop(), v = q.value; // e.g. "wmoUnit:km_h-1" -> "km_h-1"
|
||
switch (kind) {
|
||
case 'temp': return unit === 'degC' ? v * 9 / 5 + 32 : unit === 'K' ? (v - 273.15) * 9 / 5 + 32 : v;
|
||
case 'speed': return unit === 'km_h-1' ? v * 0.621371 : unit === 'm_s-1' ? v * 2.23694 : unit === 'kt' ? v * 1.15078 : v;
|
||
case 'press': return unit === 'Pa' ? v * 0.00029530 : unit === 'hPa' ? v * 0.02953 : v;
|
||
case 'dist': return unit === 'm' ? v * 0.000621371 : unit === 'km' ? v * 0.621371 : v;
|
||
case 'len': return unit === 'mm' ? v / 25.4 : unit === 'm' ? v * 39.3701 : v;
|
||
default: return v;
|
||
}
|
||
};
|
||
const r1 = (v, d = 1) => (v == null ? null : +v.toFixed(d));
|
||
|
||
function normalizeNwsObs(f) {
|
||
const p = f.properties || {};
|
||
const ts = Date.parse(p.timestamp);
|
||
if (!Number.isFinite(ts)) return null;
|
||
return {
|
||
dateutc: ts, ts, station: (p.station || '').split('/').pop() || null,
|
||
tempf: r1(nwsQ(p.temperature, 'temp')), dewPoint: r1(nwsQ(p.dewpoint, 'temp')), humidity: r1(nwsQ(p.relativeHumidity)),
|
||
windspeedmph: r1(nwsQ(p.windSpeed, 'speed')), windgustmph: r1(nwsQ(p.windGust, 'speed')), winddir: r1(nwsQ(p.windDirection), 0),
|
||
baromrelin: r1(nwsQ(p.seaLevelPressure, 'press'), 3), baromabsin: r1(nwsQ(p.barometricPressure, 'press'), 3),
|
||
visibilityMi: r1(nwsQ(p.visibility, 'dist'), 1),
|
||
rain1hIn: r1(nwsQ(p.precipitationLastHour, 'len'), 2), rain3hIn: r1(nwsQ(p.precipitationLast3Hours, 'len'), 2), rain6hIn: r1(nwsQ(p.precipitationLast6Hours, 'len'), 2),
|
||
heatIndexF: r1(nwsQ(p.heatIndex, 'temp')), windChillF: r1(nwsQ(p.windChill, 'temp')),
|
||
max24F: r1(nwsQ(p.maxTemperatureLast24Hours, 'temp')), min24F: r1(nwsQ(p.minTemperatureLast24Hours, 'temp')),
|
||
text: p.textDescription || null,
|
||
clouds: (p.cloudLayers || []).map(c => ({ amount: c.amount, baseFt: c.base?.value != null ? Math.round(c.base.value * 3.28084) : null })),
|
||
presentWeather: (p.presentWeather || []).map(w => w.weather || w.rawString).filter(Boolean),
|
||
raw: p.rawMessage || null,
|
||
};
|
||
}
|
||
|
||
const nwsSeen = new Map(); // day -> Set(ts)
|
||
function nwsDaySet(day) {
|
||
let set = nwsSeen.get(day);
|
||
if (set) return set;
|
||
set = new Set();
|
||
try { for (const l of fs.readFileSync(path.join(NWS_DIR, `${day}.jsonl`), 'utf8').split('\n')) if (l) { try { set.add(JSON.parse(l).ts); } catch {} } } catch {}
|
||
nwsSeen.set(day, set);
|
||
return set;
|
||
}
|
||
function nwsSaveObs(rec) {
|
||
const day = new Date(rec.ts).toISOString().slice(0, 10);
|
||
const set = nwsDaySet(day);
|
||
if (set.has(rec.ts)) return false;
|
||
fs.appendFileSync(path.join(NWS_DIR, `${day}.jsonl`), JSON.stringify(rec) + '\n');
|
||
set.add(rec.ts);
|
||
nwsState.obsCount++;
|
||
if (rec.ts > nwsState.lastObsTs) nwsState.lastObsTs = rec.ts;
|
||
if (!nwsState.firstObsTs || rec.ts < nwsState.firstObsTs) nwsState.firstObsTs = rec.ts;
|
||
return true;
|
||
}
|
||
|
||
async function nwsFetchObservations(res, limit) {
|
||
const r = await fetchWithRetry(`${NWS_BASE}/stations/${res.stationId}/observations?limit=${limit}`, { attempts: 2, timeoutMs: 15000, fetchOptions: NWS_HEADERS });
|
||
const feats = (await r.json()).features || [];
|
||
let added = 0;
|
||
for (const f of feats.reverse()) { const rec = normalizeNwsObs(f); if (rec && nwsSaveObs(rec)) added++; }
|
||
return { seen: feats.length, added };
|
||
}
|
||
|
||
async function nwsSnapshotForecast(res) {
|
||
if (!res.forecastHourly) return;
|
||
const r = await fetchWithRetry(res.forecastHourly, { attempts: 2, timeoutMs: 15000, fetchOptions: NWS_HEADERS });
|
||
const p = (await r.json()).properties || {};
|
||
const issued = Date.parse(p.updateTime || p.generatedAt) || Date.now();
|
||
const day = new Date(issued).toISOString().slice(0, 10);
|
||
const file = path.join(NWS_FC_DIR, `${day}.jsonl`);
|
||
try { if (fs.readFileSync(file, 'utf8').split('\n').some(l => l && JSON.parse(l).issued === issued)) return; } catch {}
|
||
const periods = (p.periods || []).slice(0, 48).map(x => ({
|
||
start: Date.parse(x.startTime), tempf: x.temperatureUnit === 'C' ? x.temperature * 9 / 5 + 32 : x.temperature,
|
||
pop: x.probabilityOfPrecipitation?.value ?? null, wind: x.windSpeed, windDir: x.windDirection,
|
||
humidity: x.relativeHumidity?.value ?? null, dewC: x.dewpoint?.value ?? null, short: x.shortForecast,
|
||
}));
|
||
fs.appendFileSync(file, JSON.stringify({ ts: Date.now(), issued, station: res.stationId, zip: res.zip, periods }) + '\n');
|
||
}
|
||
|
||
async function nwsPoll() {
|
||
if (nwsState.running) return;
|
||
nwsState.running = true;
|
||
try {
|
||
const first = !nwsSaved().backfilled;
|
||
const res = await nwsResolve();
|
||
const out = await nwsFetchObservations(res, first ? 500 : 15); // first run: everything NWS still keeps (~7 days)
|
||
if (first && out.seen) nwsSave({ backfilled: Date.now() });
|
||
if (Date.now() - nwsState.lastForecastAt > 3 * 3600000) { await nwsSnapshotForecast(res); nwsState.lastForecastAt = Date.now(); }
|
||
nwsState.lastError = null;
|
||
nwsState.lastPollAt = Date.now();
|
||
if (out.added) console.log(`[nws] logged ${out.added} observation(s) from ${res.stationId}`);
|
||
} catch (e) {
|
||
nwsState.lastError = e.message;
|
||
console.error('[nws] poll error:', e.message);
|
||
}
|
||
nwsState.running = false;
|
||
}
|
||
|
||
function loadNwsReadings(rangeMs) {
|
||
const now = Date.now(), cutoff = now - rangeMs;
|
||
const days = Math.min(Math.ceil(rangeMs / 86400000) + 1, 4000);
|
||
const out = [];
|
||
for (let i = 0; i < days; i++) {
|
||
const day = stationDateKey(now - i * 86400000);
|
||
try {
|
||
for (const l of fs.readFileSync(path.join(NWS_DIR, `${day}.jsonl`), 'utf8').split('\n')) {
|
||
if (!l) continue;
|
||
try { const o = JSON.parse(l); if (o.ts >= cutoff) { delete o.raw; out.push(o); } } catch {}
|
||
}
|
||
} catch {}
|
||
}
|
||
return out.sort((a, b) => a.ts - b.ts);
|
||
}
|
||
// Index what's already on disk so the status shows the right totals after a restart
|
||
(function nwsIndex() {
|
||
try {
|
||
for (const f of fs.readdirSync(NWS_DIR).filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)).sort()) {
|
||
for (const l of fs.readFileSync(path.join(NWS_DIR, f), 'utf8').split('\n')) {
|
||
if (!l) continue;
|
||
try { const t = JSON.parse(l).ts; nwsState.obsCount++; if (t > nwsState.lastObsTs) nwsState.lastObsTs = t; if (!nwsState.firstObsTs || t < nwsState.firstObsTs) nwsState.firstObsTs = t; } catch {}
|
||
}
|
||
}
|
||
} catch {}
|
||
})();
|
||
setTimeout(nwsPoll, 20 * 1000).unref();
|
||
setInterval(nwsPoll, 10 * 60 * 1000).unref();
|
||
|
||
// Station history API
|
||
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);
|
||
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; }
|
||
}
|
||
|
||
// ── NWS reference data API ───────────────────────────────────────────────────
|
||
function nwsStatus() {
|
||
const c = nwsSaved();
|
||
return { zip: c.zip || '24550', resolved: c.resolved || null, observations: nwsState.obsCount, firstObservation: nwsState.firstObsTs, lastObservation: nwsState.lastObsTs || null, lastPoll: nwsState.lastPollAt || null, lastError: nwsState.lastError };
|
||
}
|
||
app.get('/api/nws/config', requireDashboardAuth, (_req, res) => res.json(nwsStatus()));
|
||
app.post('/api/nws/config', requireDashboardAuth, requireAdmin, async (req, res) => {
|
||
const zip = String(req.body?.zip || '').trim();
|
||
if (!/^\d{5}$/.test(zip)) return res.status(400).json({ error: 'Enter a 5-digit US ZIP code' });
|
||
nwsSave({ zip, resolved: null, lat: undefined, lon: undefined, backfilled: undefined });
|
||
nwsState.lastObsTs = 0; nwsState.lastForecastAt = 0;
|
||
try { await nwsResolve(true); } catch (e) { return res.status(502).json({ error: `Could not set up that ZIP: ${e.message}` }); }
|
||
nwsPoll();
|
||
res.json(nwsStatus());
|
||
});
|
||
app.get('/api/nws/latest', requireDashboardAuth, (_req, res) => {
|
||
const recent = loadNwsReadings(6 * 3600000);
|
||
const latest = recent[recent.length - 1] || null;
|
||
let raw = null;
|
||
if (latest) { try { const day = new Date(latest.ts).toISOString().slice(0, 10); for (const l of fs.readFileSync(path.join(NWS_DIR, `${day}.jsonl`), 'utf8').split('\n')) { if (l && JSON.parse(l).ts === latest.ts) raw = JSON.parse(l).raw; } } catch {} }
|
||
res.json({ ...nwsStatus(), latest: latest ? { ...latest, raw } : null });
|
||
});
|
||
app.get('/api/nws/history', requireDashboardAuth, (req, res) => {
|
||
const range = String(req.query.range || '24h');
|
||
const map = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '2d': 172800000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
|
||
let ms = map[range];
|
||
if (range === 'all') ms = 4000 * 86400000;
|
||
res.json(loadNwsReadings(ms || map['24h']));
|
||
});
|
||
|
||
// Latest station reading
|
||
app.get('/api/station/latest', requireDashboardAuth, async (req, res) => {
|
||
if (!ambientServerConfig.apiKey || !ambientServerConfig.appKey) {
|
||
return res.status(503).json({ error: 'Station not configured' });
|
||
}
|
||
const cached = ambientCaches.get(ambientServerConfig.apiKey);
|
||
if (cached) return res.json(cached.data?.[0]?.lastData || null);
|
||
res.json(null);
|
||
});
|
||
|
||
// ── Ambient historical backfill ───────────────────────────────────────────────
|
||
let backfillState = { running: false, done: 0, total: 0, inserted: 0, error: null };
|
||
|
||
async function runBackfill(daysBack) {
|
||
const apiKey = ambientServerConfig.apiKey;
|
||
const appKey = ambientServerConfig.appKey;
|
||
if (!apiKey || !appKey) {
|
||
backfillState = { running: false, done: 0, total: 0, inserted: 0, error: 'No API keys configured' };
|
||
return;
|
||
}
|
||
|
||
// Get device MAC address
|
||
let mac;
|
||
try {
|
||
const r = await fetchWithRetry(
|
||
`https://rt.ambientweather.net/v1/devices?apiKey=${encodeURIComponent(apiKey)}&applicationKey=${encodeURIComponent(appKey)}`,
|
||
{ attempts: 3, timeoutMs: 15000 }
|
||
);
|
||
const devices = await r.json();
|
||
mac = devices?.[0]?.macAddress;
|
||
} catch (e) {
|
||
backfillState = { running: false, done: 0, total: 0, inserted: 0, error: `Device fetch failed: ${e.message}` };
|
||
return;
|
||
}
|
||
|
||
if (!mac) {
|
||
backfillState = { running: false, done: 0, total: 0, inserted: 0, error: 'No device found for these keys' };
|
||
return;
|
||
}
|
||
|
||
const cutoff = Date.now() - daysBack * 86400000;
|
||
backfillState = { running: true, done: 0, total: daysBack, inserted: 0, error: null };
|
||
|
||
let endDate = Date.now();
|
||
let inserted = 0;
|
||
|
||
while (endDate > cutoff) {
|
||
await new Promise(r => setTimeout(r, 1100)); // respect 1 req/sec rate limit
|
||
try {
|
||
const url = `https://rt.ambientweather.net/v1/devices/${encodeURIComponent(mac)}?apiKey=${encodeURIComponent(apiKey)}&applicationKey=${encodeURIComponent(appKey)}&limit=288&endDate=${endDate}`;
|
||
const r = await fetchWithRetry(url, { attempts: 2, timeoutMs: 15000 });
|
||
const records = await r.json();
|
||
|
||
if (!Array.isArray(records) || records.length === 0) break;
|
||
|
||
let oldest = Infinity;
|
||
for (const rec of records) {
|
||
if (!rec.dateutc) continue;
|
||
if (rec.dateutc < oldest) oldest = rec.dateutc;
|
||
if (rec.dateutc < cutoff) continue;
|
||
if (saveStationReading(rec)) inserted++;
|
||
}
|
||
|
||
backfillState.done = Math.min(daysBack, Math.ceil((Date.now() - oldest) / 86400000));
|
||
backfillState.inserted = inserted;
|
||
|
||
if (records.length < 288 || oldest <= cutoff) break;
|
||
endDate = oldest - 1;
|
||
} catch (e) {
|
||
backfillState.error = e.message;
|
||
break;
|
||
}
|
||
}
|
||
|
||
backfillState.running = false;
|
||
backfillState.done = daysBack;
|
||
backfillState.inserted = inserted;
|
||
console.log(`[backfill] complete — inserted ${inserted} records`);
|
||
}
|
||
|
||
app.post('/api/station/backfill', requireDashboardAuth, requireAdmin, (req, res) => {
|
||
if (backfillState.running) return res.json({ ok: false, error: 'Backfill already running' });
|
||
const days = Math.min(Math.max(parseInt(req.body?.days || '30'), 1), 365);
|
||
res.json({ ok: true, days });
|
||
runBackfill(days).catch(e => {
|
||
backfillState.error = e.message;
|
||
backfillState.running = false;
|
||
});
|
||
});
|
||
|
||
app.get('/api/station/backfill/status', requireDashboardAuth, (_req, res) => {
|
||
res.json(backfillState);
|
||
});
|
||
|
||
// ── Station analytics ─────────────────────────────────────────────────────────
|
||
|
||
// Records — derived from the persistent per-day store (data/records.json)
|
||
app.get('/api/station/records', requireDashboardAuth, (_req, res) => {
|
||
res.json(deriveRecords());
|
||
});
|
||
|
||
// Public, minimal station context for the weather app (no raw readings): yesterday / today
|
||
// extremes and the station's own average high/low for this date from earlier years.
|
||
app.get('/api/station/summary', (_req, res) => {
|
||
const days = recordsStore.days;
|
||
const fmtDay = (ts) => new Intl.DateTimeFormat('en-CA', { timeZone: stationTzFallback }).format(new Date(ts));
|
||
const todayKey = fmtDay(Date.now());
|
||
const yKey = fmtDay(Date.now() - 86400000);
|
||
const pick = (k) => {
|
||
const d = days[k];
|
||
return d ? { day: k, hi: d.hi?.[0] ?? null, lo: d.lo?.[0] ?? null, rain: d.rain ?? null, gust: d.gust?.[0] ?? null } : null;
|
||
};
|
||
// Normals: same calendar date ±3 days in earlier years (needs a prior year of data)
|
||
const [ty, tm, td] = todayKey.split('-').map(Number);
|
||
const his = [], los = [];
|
||
for (const [k, d] of Object.entries(days)) {
|
||
const [y, m, dd] = k.split('-').map(Number);
|
||
if (y >= ty) continue;
|
||
const diff = Math.abs(Date.UTC(2001, m - 1, dd) - Date.UTC(2001, tm - 1, td)) / 86400000;
|
||
if (Math.min(diff, 365 - diff) <= 3) { if (d.hi) his.push(d.hi[0]); if (d.lo) los.push(d.lo[0]); }
|
||
}
|
||
const avg = (a) => (a.length >= 3 ? +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(1) : null);
|
||
res.json({ today: pick(todayKey), yesterday: pick(yKey), normal: { hi: avg(his), lo: avg(los), samples: his.length }, daysRecorded: Object.keys(days).length });
|
||
});
|
||
|
||
// Per-day summaries (extremes, average temp, rain, wind-direction histogram) for charts/insights
|
||
app.get('/api/station/daily', requireDashboardAuth, (req, res) => {
|
||
const limit = Math.min(Math.max(parseInt(req.query.days) || 0, 0), 5000);
|
||
let entries = Object.entries(recordsStore.days).sort(([a], [b]) => a.localeCompare(b));
|
||
if (limit) entries = entries.slice(-limit);
|
||
res.json(entries.map(([d, v]) => ({
|
||
d, n: v.n || 0,
|
||
hi: v.hi?.[0] ?? null, hiT: v.hi?.[1] ?? null, lo: v.lo?.[0] ?? null, loT: v.lo?.[1] ?? null,
|
||
avg: v.tn ? +(v.tsum / v.tn).toFixed(1) : null,
|
||
gust: v.gust?.[0] ?? null, gustT: v.gust?.[1] ?? null,
|
||
rain: v.rain ?? null, rate: v.rate?.[0] ?? null,
|
||
uv: v.uv?.[0] ?? null, solar: v.solar?.[0] ?? null,
|
||
pmax: v.pmax?.[0] ?? null, pmin: v.pmin?.[0] ?? null,
|
||
hmax: v.hmax?.[0] ?? null, hmin: v.hmin?.[0] ?? null,
|
||
wd: v.wd || null,
|
||
})));
|
||
});
|
||
|
||
// Re-scan raw readings into the records store (merge only; never lowers a record)
|
||
app.post('/api/station/records/rebuild', requireDashboardAuth, requireAdmin, (_req, res) => {
|
||
const scanned = rebuildRecordsFromFiles();
|
||
res.json({ ok: true, scanned });
|
||
});
|
||
|
||
// Full backup: every saved reading (deduped, oldest first) as newline-delimited JSON
|
||
app.get('/api/station/backup.jsonl', requireDashboardAuth, (_req, res) => {
|
||
let files;
|
||
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { files = []; }
|
||
res.setHeader('Content-Type', 'application/x-ndjson');
|
||
res.setHeader('Content-Disposition', `attachment; filename="station-backup-${new Date().toISOString().slice(0, 10)}.jsonl"`);
|
||
for (const f of files) {
|
||
try {
|
||
const seen = new Set();
|
||
const rows = [];
|
||
for (const l of fs.readFileSync(path.join(STATION_DIR, f), 'utf8').split('\n')) {
|
||
if (!l) continue;
|
||
try { const t = JSON.parse(l).dateutc; if (seen.has(t)) continue; seen.add(t); } catch {}
|
||
rows.push(l);
|
||
}
|
||
if (rows.length) res.write(rows.join('\n') + '\n');
|
||
} catch {}
|
||
}
|
||
res.end();
|
||
});
|
||
|
||
// Records store (per-day extremes) as JSON
|
||
app.get('/api/station/records-backup.json', requireDashboardAuth, (_req, res) => {
|
||
res.setHeader('Content-Disposition', `attachment; filename="station-records-${new Date().toISOString().slice(0, 10)}.json"`);
|
||
res.json(recordsStore);
|
||
});
|
||
|
||
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 = stationLocalDay(r);
|
||
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 = stationLocalDay(r);
|
||
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 });
|
||
});
|
||
|
||
// The old standalone /conditions page now lives in the app as the Station tab
|
||
app.get('/conditions', (_req, res) => res.redirect(301, '/?tab=station'));
|
||
|
||
// ── Dashboard management APIs ─────────────────────────────────────────────────
|
||
|
||
// Who am I
|
||
app.get('/api/dashboard/me', requireDashboardAuth, (req, res) => {
|
||
res.json({ username: req.dashUser.username, role: req.dashUser.role });
|
||
});
|
||
|
||
// Change own password (signs out other sessions, keeps this one)
|
||
app.post('/api/dashboard/change-password', requireDashboardAuth, (req, res) => {
|
||
const { currentPassword, newPassword } = req.body || {};
|
||
if (!currentPassword || !newPassword || newPassword.length < PASSWORD_MIN)
|
||
return res.status(400).json({ error: `New password must be at least ${PASSWORD_MIN} characters` });
|
||
const user = req.dashUser;
|
||
const key = 'pw|' + user.username.toLowerCase();
|
||
if (isLockedOut(key)) return res.status(429).json({ error: 'Too many attempts — try again in a few minutes' });
|
||
if (!checkPassword(user, currentPassword)) { noteLoginFailure(key); return res.status(403).json({ error: 'Current password incorrect' }); }
|
||
loginAttempts.delete(key);
|
||
setPassword(user, newPassword);
|
||
saveUsers();
|
||
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ── User management (admin only) ──────────────────────────────────────────────
|
||
app.get('/api/users', requireDashboardAuth, requireAdmin, (_req, res) => {
|
||
res.json(usersStore.users.map(publicUser));
|
||
});
|
||
|
||
app.post('/api/users', requireDashboardAuth, requireAdmin, (req, res) => {
|
||
const { username, password, role } = req.body || {};
|
||
if (!USERNAME_RE.test(String(username || ''))) return res.status(400).json({ error: 'Username must be 3–32 characters: letters, numbers, . _ -' });
|
||
if (!password || password.length < PASSWORD_MIN) return res.status(400).json({ error: `Password must be at least ${PASSWORD_MIN} characters` });
|
||
if (!ROLES.includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
|
||
if (findUser(username)) return res.status(409).json({ error: 'That username is already taken' });
|
||
const user = { username, role, epoch: 0, createdAt: Date.now() };
|
||
setPassword(user, password); user.epoch = 0;
|
||
usersStore.users.push(user);
|
||
saveUsers();
|
||
console.log(`[auth] ${req.dashUser.username} created ${role} "${username}"`);
|
||
res.status(201).json(publicUser(user));
|
||
});
|
||
|
||
app.patch('/api/users/:username', requireDashboardAuth, requireAdmin, (req, res) => {
|
||
const user = findUser(req.params.username);
|
||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||
const { role, password, disabled } = req.body || {};
|
||
const isSelf = user.username === req.dashUser.username;
|
||
const losesAdmin = (role && role !== 'admin') || disabled === true;
|
||
if (user.role === 'admin' && !user.disabled && losesAdmin) {
|
||
if (isSelf) return res.status(400).json({ error: "You can't demote or disable your own account" });
|
||
if (activeAdmins().length <= 1) return res.status(400).json({ error: 'There must be at least one active admin' });
|
||
}
|
||
if (role !== undefined) {
|
||
if (!ROLES.includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
|
||
user.role = role;
|
||
}
|
||
if (disabled !== undefined) {
|
||
user.disabled = !!disabled;
|
||
if (user.disabled) user.epoch = (user.epoch || 0) + 1;
|
||
}
|
||
if (password !== undefined) {
|
||
if (typeof password !== 'string' || password.length < PASSWORD_MIN) return res.status(400).json({ error: `Password must be at least ${PASSWORD_MIN} characters` });
|
||
setPassword(user, password);
|
||
clearAttemptsFor(user.username);
|
||
if (isSelf) res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
|
||
}
|
||
saveUsers();
|
||
console.log(`[auth] ${req.dashUser.username} updated "${user.username}"`);
|
||
res.json(publicUser(user));
|
||
});
|
||
|
||
app.delete('/api/users/:username', requireDashboardAuth, requireAdmin, (req, res) => {
|
||
const user = findUser(req.params.username);
|
||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||
if (user.username === req.dashUser.username) return res.status(400).json({ error: "You can't delete your own account" });
|
||
if (user.role === 'admin' && !user.disabled && activeAdmins().length <= 1) return res.status(400).json({ error: 'There must be at least one active admin' });
|
||
usersStore.users = usersStore.users.filter(u => u !== user);
|
||
saveUsers();
|
||
console.log(`[auth] ${req.dashUser.username} deleted "${user.username}"`);
|
||
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, requireAdmin, (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 day = stationLocalDay(r);
|
||
const key = day.slice(0, 7); // YYYY-MM (local)
|
||
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) {
|
||
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, requireAdmin, (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 });
|
||
});
|
||
|
||
// Station location (for dashboard alerts)
|
||
app.get('/api/dashboard/location', requireDashboardAuth, (_req, res) => {
|
||
if (dashAuth?.stationLat != null)
|
||
return res.json({ lat: dashAuth.stationLat, lon: dashAuth.stationLon });
|
||
if (pushSubscriptions.length)
|
||
return res.json({ lat: pushSubscriptions[0].lat, lon: pushSubscriptions[0].lon, fromSubscription: true });
|
||
res.json(null);
|
||
});
|
||
app.post('/api/dashboard/location', requireDashboardAuth, requireAdmin, (req, res) => {
|
||
const { lat, lon } = req.body || {};
|
||
if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180))
|
||
return res.status(400).json({ error: 'Invalid coordinates' });
|
||
dashAuth = { ...dashAuth, stationLat: +lat, stationLon: +lon };
|
||
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
|
||
dashAlertCache.clear();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// NWS alerts for the station location (5-min cache)
|
||
const dashAlertCache = new Map();
|
||
app.get('/api/dashboard/alerts', requireDashboardAuth, async (_req, res) => {
|
||
let lat = dashAuth?.stationLat;
|
||
let lon = dashAuth?.stationLon;
|
||
if (lat == null && pushSubscriptions.length) { lat = pushSubscriptions[0].lat; lon = pushSubscriptions[0].lon; }
|
||
if (lat == null) return res.json({ features: [], noLocation: true });
|
||
const cacheKey = `${lat},${lon}`;
|
||
const cached = dashAlertCache.get(cacheKey);
|
||
if (cached && Date.now() - cached.at < 5 * 60 * 1000) return res.json(cached.data);
|
||
try {
|
||
const r = await fetchWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
|
||
attempts: 1, timeoutMs: 12000,
|
||
fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } },
|
||
});
|
||
const data = await r.json();
|
||
dashAlertCache.set(cacheKey, { data, at: Date.now() });
|
||
res.json(data);
|
||
} catch { res.json({ features: [] }); }
|
||
});
|
||
|
||
// Consecutive dry/wet streak (from daily rain totals)
|
||
app.get('/api/station/streak', requireDashboardAuth, (_req, res) => {
|
||
const readings = loadStationReadings(60 * 86400000);
|
||
const byDay = {};
|
||
for (const r of readings) {
|
||
if (r.dailyrainin == null || !r.dateutc) continue;
|
||
const d = stationLocalDay(r);
|
||
if (byDay[d] == null || r.dailyrainin > byDay[d]) byDay[d] = r.dailyrainin;
|
||
}
|
||
const days = Object.keys(byDay).sort().reverse();
|
||
if (!days.length) return res.json({ type: null, count: 0 });
|
||
const latestRain = byDay[days[0]];
|
||
const streakType = latestRain >= 0.01 ? 'wet' : 'dry';
|
||
let count = 0;
|
||
for (const d of days) {
|
||
const rain = byDay[d];
|
||
if (streakType === 'wet' ? rain >= 0.01 : rain < 0.01) count++;
|
||
else break;
|
||
}
|
||
res.json({ type: streakType, count });
|
||
});
|
||
|
||
// 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'),
|
||
});
|
||
});
|
||
|
||
// Alert history log
|
||
// ── NWS alert details (looked up by alert id, cached on disk) ────────────────
|
||
const ALERT_DETAIL_FILE = path.join(DATA_DIR, 'alert-details.json');
|
||
let alertDetails = {};
|
||
try { alertDetails = JSON.parse(fs.readFileSync(ALERT_DETAIL_FILE, 'utf8')); } catch {}
|
||
let alertDetailsDirty = false;
|
||
function saveAlertDetails() {
|
||
if (!alertDetailsDirty) return;
|
||
try { fs.writeFileSync(ALERT_DETAIL_FILE + '.tmp', JSON.stringify(alertDetails)); fs.renameSync(ALERT_DETAIL_FILE + '.tmp', ALERT_DETAIL_FILE); alertDetailsDirty = false; }
|
||
catch (e) { console.error('[alerts] detail cache save error:', e.message); }
|
||
}
|
||
setInterval(saveAlertDetails, 30 * 1000).unref();
|
||
|
||
const ALERT_ID_RE = /^https:\/\/api\.weather\.gov\/alerts\/[A-Za-z0-9:._-]+$/;
|
||
const alertLookupsInFlight = new Map();
|
||
async function lookupAlertDetail(id) {
|
||
const hit = alertDetails[id];
|
||
if (hit && !hit.unavailable) return hit;
|
||
if (hit && hit.unavailable && Date.now() - hit.at < 6 * 3600000) return hit; // don't hammer NWS for gone alerts
|
||
if (alertLookupsInFlight.has(id)) return alertLookupsInFlight.get(id);
|
||
const job = (async () => {
|
||
try {
|
||
const r = await fetchWithRetry(id, { attempts: 1, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } } });
|
||
const f = await r.json();
|
||
const p = f.properties || f;
|
||
if (!p.event) throw new Error('no event');
|
||
alertDetails[id] = { ...nwsLogFields(p), fetchedAt: Date.now() };
|
||
} catch (e) {
|
||
alertDetails[id] = { unavailable: true, at: Date.now() };
|
||
}
|
||
alertDetailsDirty = true;
|
||
alertLookupsInFlight.delete(id);
|
||
return alertDetails[id];
|
||
})();
|
||
alertLookupsInFlight.set(id, job);
|
||
return job;
|
||
}
|
||
|
||
app.get('/api/dashboard/alert-detail', requireDashboardAuth, async (req, res) => {
|
||
const id = String(req.query.id || '');
|
||
if (!ALERT_ID_RE.test(id)) return res.status(400).json({ error: 'Invalid alert id' });
|
||
const d = await lookupAlertDetail(id);
|
||
if (d.unavailable) return res.status(404).json({ error: 'NWS no longer provides this alert' });
|
||
res.json(d);
|
||
});
|
||
|
||
app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
|
||
const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30));
|
||
const results = [];
|
||
const now = Date.now();
|
||
for (let i = 0; i < days; i++) {
|
||
const day = new Date(now - i * 86400000).toISOString().slice(0, 10);
|
||
const file = path.join(ALERTS_DIR, `${day}.jsonl`);
|
||
if (!fs.existsSync(file)) continue;
|
||
try {
|
||
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
||
for (const line of lines) {
|
||
try { results.push(JSON.parse(line)); } catch {}
|
||
}
|
||
} catch {}
|
||
}
|
||
// Fill in text/area for older entries from the detail cache when we have it
|
||
for (const e of results) {
|
||
if (e.type !== 'nws' || e.description || !e.alertId) continue;
|
||
const d = alertDetails[e.alertId];
|
||
if (d && !d.unavailable) Object.assign(e, Object.fromEntries(Object.entries(d).filter(([k, v]) => v != null && e[k] == null)));
|
||
}
|
||
results.sort((a, b) => b.ts - a.ts);
|
||
res.json(results);
|
||
});
|
||
|
||
app.listen(PORT, () => {
|
||
console.log(`Weather app running on port ${PORT}`);
|
||
if (hasUsers()) {
|
||
console.log(`Dashboard recovery token (changes daily): ${getDailyRecoveryToken()}`);
|
||
console.log(` Use at http://localhost:${PORT}/dashboard/recover`);
|
||
}
|
||
});
|