Files
Nate EmmertandClaude Sonnet 5.5 9515d1af00
Publish Container / publish (push) Successful in 2m29s
Dashboard coverage: count 5-minute slots with data, not readings vs the recent interval
Coverage divided each day's reading count by 1440/median-recent-interval, so days logged at a
slower pace (100 s, 120 s) showed as 50-60% complete once uploads sped up to 60 s. Each day now
records which of its 288 five-minute slots have a reading (backfilled from raw files on startup),
so coverage is independent of upload interval. Older days without slot data fall back to the old maths.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 10:46:44 -04:00

2464 lines
116 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
// Local 5-minute slot (0-287) of a reading, for interval-independent coverage.
const slotFmtCache = new Map();
function stationLocalSlot(r) {
const tz = r.tz || stationTzFallback;
let f = slotFmtCache.get(tz);
if (!f) {
try { f = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hourCycle: 'h23', hour: '2-digit', minute: '2-digit' }); }
catch { f = new Intl.DateTimeFormat('en-GB', { timeZone: stationTzFallback, hourCycle: 'h23', hour: '2-digit', minute: '2-digit' }); }
slotFmtCache.set(tz, f);
}
const p = Object.fromEntries(f.formatToParts(new Date(r.dateutc)).map(x => [x.type, x.value]));
return Math.min(287, Math.floor((Number(p.hour) * 60 + Number(p.minute)) / 5));
}
// day.sl is a 288-char string of 0/1: which 5-minute slots of the local day have at least one reading.
function markSlot(day, slot) {
const sl = day.sl && day.sl.length === 288 ? day.sl : '0'.repeat(288);
if (sl[slot] === '1') { day.sl = sl; return false; }
day.sl = sl.slice(0, slot) + '1' + sl.slice(slot + 1);
return true;
}
// 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;
markSlot(day, stationLocalSlot(obs));
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 = {};
const slotsFresh = {};
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;
markSlot(slotsFresh[k] || (slotsFresh[k] = {}), stationLocalSlot(r));
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);
// union with what live recording already marked (pruned raw files must not lose coverage)
const fs_ = slotsFresh[k]?.sl;
if (fs_) { const cur = day.sl && day.sl.length === 288 ? day.sl : '0'.repeat(288); day.sl = [...fs_].map((c, i) => (c === '1' || cur[i] === '1') ? '1' : '0').join(''); }
// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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> &middot; <a href="/dashboard/logout">Sign out</a> &middot; <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,
slots: v.sl ? (v.sl.match(/1/g) || []).length : null, // 5-min slots with data (of 288)
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`);
}
});