Files
weather/app/server.js
T
Nate EmmertandClaude Sonnet 5.5 72a4a3fe38
Publish Container / publish (push) Successful in 2m44s
Alerts tab: fix hurricane/tropical storm bugs; rich alert cards matching the dashboard
Hurricane / tropical storm fixes
- NHC intensity is in knots: show real mph (80 kt = 92 mph) instead of mislabelled 'mph'
- Real Saffir-Simpson category from wind (was always cat 1); subtropical, post-tropical and
  potential-cyclone types no longer shown as 'depression'; each storm shows movement
  (direction + mph, previously a non-existent field), distance from you, update time, advisory link
- Forecast track points coloured by their own forecast strength (not the current class)
- Map click handlers registered once (popups stacked on every refresh); overlays wait for the
  map to settle instead of silently not drawing while tiles load; stale renders superseded
- Subtropical/tropical advice added to alert guidance; escaped storm names

Alert cards (active alerts only)
- What it's for, area, window, time remaining, plain-language meaning, what to do, and a
  Full details expander (headline, text, instructions, severity/urgency/certainty, issuer)
- No longer drops alerts beyond 6 per severity group; expired alerts are filtered out
- Shared plain-language alert glossary (alert-info.js, 64 products incl. tropical and surge)
  now used by both the app and the dashboard history

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 14:00:41 -04:00

2368 lines
109 KiB
JavaScript
Raw 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));
}
// Per-day set of dateutc values already on disk. Shared by the live recorder and
// the backfill so neither can write a reading the other (or a prior run) saved.
const stationSeen = new Map();
function stationDayFile(day) {
return path.join(STATION_DIR, `${day}.jsonl`);
}
function getStationSeen(day) {
let set = stationSeen.get(day);
if (set) return set;
set = new Set();
try {
for (const l of fs.readFileSync(stationDayFile(day), 'utf8').split('\n')) {
if (!l) continue;
try { set.add(JSON.parse(l).dateutc); } catch {}
}
} catch {}
stationSeen.set(day, set);
return set;
}
// A reading is only saved if it has a sane timestamp and isn't already stored.
// Returns true when a new row was written.
function saveStationReading(obs) {
const ts = obs && obs.dateutc;
if (!Number.isFinite(ts) || ts <= 0 || ts > Date.now() + 5 * 60 * 1000) return false;
const day = stationDateKey(ts);
const seen = getStationSeen(day);
if (seen.has(ts)) return false;
fs.appendFileSync(stationDayFile(day), JSON.stringify(obs) + '\n');
seen.add(ts);
if (typeof updateRecordsFromReading === 'function') {
try { updateRecordsFromReading(obs); } catch (e) { console.error('[records] update error:', e.message); }
}
return true;
}
function recordStationReading(obs) {
try {
if (obs && obs.tz) stationTzFallback = obs.tz;
saveStationReading(obs);
} catch (e) {
console.error('[station] record error:', e.message);
}
}
// One-time startup pass: drop duplicate dateutc rows from existing day files.
// Rewrites a file (tmp + rename) only when it actually contains duplicates.
function dedupeStationFiles() {
let files;
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')); } catch { return; }
for (const f of files) {
try {
const file = path.join(STATION_DIR, f);
const seen = new Set();
const keep = [];
let dropped = 0;
for (const l of fs.readFileSync(file, 'utf8').split('\n')) {
if (!l) continue;
let ts;
try { ts = JSON.parse(l).dateutc; } catch { keep.push(l); continue; }
if (ts != null && seen.has(ts)) { dropped++; continue; }
seen.add(ts);
keep.push(l);
}
if (dropped) {
fs.writeFileSync(file + '.tmp', keep.join('\n') + '\n');
fs.renameSync(file + '.tmp', file);
console.log(`[station] ${f}: removed ${dropped} duplicate reading(s)`);
}
} catch (e) {
console.error('[station] dedupe error:', f, e.message);
}
}
}
dedupeStationFiles();
// ── Persistent all-time records ───────────────────────────────────────────────
// data/records.json keeps one small summary per local day (extremes + the time they
// happened). All-time / monthly / yearly records are derived from it, so they survive
// even if raw readings are pruned or lost. Merging only ever widens an extreme.
const RECORDS_FILE = path.join(DATA_DIR, 'records.json');
let recordsStore = { version: 1, days: {} };
try {
const loaded = JSON.parse(fs.readFileSync(RECORDS_FILE, 'utf8'));
if (loaded && loaded.days) recordsStore = loaded;
} catch {}
let recordsDirty = false;
// [field, source key(s), 'max' | 'min']
const DAY_FIELDS = [
['hi', ['tempf'], 'max'], ['lo', ['tempf'], 'min'],
['gust', ['windgustmph', 'maxdailygust'], 'max'],
['rate', ['rainratein'], 'max'],
['uv', ['uv'], 'max'], ['solar', ['solarradiation'], 'max'],
['pmax', ['baromrelin'], 'max'], ['pmin', ['baromrelin'], 'min'],
['hmax', ['humidity'], 'max'], ['hmin', ['humidity'], 'min'],
];
function mergeExtreme(day, field, kind, val, ts) {
if (!Number.isFinite(val)) return false;
const cur = day[field];
if (!cur || (kind === 'max' ? val > cur[0] : val < cur[0])) { day[field] = [val, ts]; return true; }
return false;
}
function applyReadingToRecords(obs) {
const dayKey = stationLocalDay(obs);
const day = recordsStore.days[dayKey] || (recordsStore.days[dayKey] = { n: 0 });
let changed = false;
for (const [field, keys, kind] of DAY_FIELDS) {
for (const k of keys) {
if (obs[k] != null && mergeExtreme(day, field, kind, Number(obs[k]), obs.dateutc)) changed = true;
}
}
if (obs.dailyrainin != null && Number.isFinite(Number(obs.dailyrainin)) && (day.rain == null || Number(obs.dailyrainin) > day.rain)) {
day.rain = Number(obs.dailyrainin); changed = true;
}
return changed;
}
// Non-idempotent aggregates (sum/count/direction histogram). Applied once per saved reading;
// a rebuild recomputes them from the raw files instead of adding on top.
function addAggregates(agg, obs) {
if (Number.isFinite(Number(obs.tempf)) && obs.tempf != null) { agg.tsum = (agg.tsum || 0) + Number(obs.tempf); agg.tn = (agg.tn || 0) + 1; }
if (obs.winddir != null && Number.isFinite(Number(obs.winddir)) && Number(obs.windspeedmph) > 0.5) {
const wd = agg.wd || (agg.wd = new Array(16).fill(0));
wd[Math.round(Number(obs.winddir) / 22.5) % 16]++;
}
}
function saveRecordsStore() {
if (!recordsDirty) return;
try {
fs.writeFileSync(RECORDS_FILE + '.tmp', JSON.stringify(recordsStore));
fs.renameSync(RECORDS_FILE + '.tmp', RECORDS_FILE);
recordsDirty = false;
} catch (e) { console.error('[records] save error:', e.message); }
}
function updateRecordsFromReading(obs) {
applyReadingToRecords(obs);
const day = recordsStore.days[stationLocalDay(obs)];
day.n = (day.n || 0) + 1;
addAggregates(day, obs);
recordsDirty = true;
}
// Re-scan raw readings and merge into the store (never lowers an existing record).
function rebuildRecordsFromFiles() {
let files;
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return 0; }
const counts = {};
const fresh = {};
let scanned = 0;
for (const f of files) {
try {
const seen = new Set();
for (const l of fs.readFileSync(path.join(STATION_DIR, f), 'utf8').split('\n')) {
if (!l) continue;
let r; try { r = JSON.parse(l); } catch { continue; }
if (!Number.isFinite(r.dateutc) || seen.has(r.dateutc)) continue;
seen.add(r.dateutc);
applyReadingToRecords(r);
const k = stationLocalDay(r);
counts[k] = (counts[k] || 0) + 1;
addAggregates(fresh[k] || (fresh[k] = {}), r);
scanned++;
}
} catch {}
}
// Reading counts come from the files; keep the larger so pruned history isn't forgotten.
for (const [k, n] of Object.entries(counts)) {
const day = recordsStore.days[k];
day.n = Math.max(day.n || 0, n);
// Replace aggregates only when the files hold at least as many samples as the store did
const f = fresh[k];
if (f.tn != null && f.tn >= (day.tn || 0)) { day.tsum = f.tsum; day.tn = f.tn; }
if (f.wd && (!day.wd || f.wd.reduce((a, b) => a + b, 0) >= day.wd.reduce((a, b) => a + b, 0))) day.wd = f.wd;
}
recordsDirty = true;
saveRecordsStore();
return scanned;
}
function deriveRecords() {
const days = Object.entries(recordsStore.days).sort(([a], [b]) => a.localeCompare(b));
if (!days.length) return null;
const top = (field, kind) => {
let best = null, bestDay = null;
for (const [d, v] of days) {
const e = v[field]; if (!e) continue;
if (best == null || (kind === 'max' ? e[0] > best[0] : e[0] < best[0])) { best = e; bestDay = d; }
}
return best ? { value: best[0], ts: best[1], day: bestDay } : null;
};
const rec = {
highTemp: top('hi', 'max'), lowTemp: top('lo', 'min'), highGust: top('gust', 'max'),
highRainRate: top('rate', 'max'), highUV: top('uv', 'max'), highSolar: top('solar', 'max'),
highPressure: top('pmax', 'max'), lowPressure: top('pmin', 'min'),
highHumidity: top('hmax', 'max'), lowHumidity: top('hmin', 'min'),
};
let wet = null;
for (const [d, v] of days) if (v.rain != null && (!wet || v.rain > wet.value)) wet = { value: v.rain, day: d };
rec.highDailyRain = wet;
const out = {};
const flat = {
highTemp: 'highTemp', lowTemp: 'lowTemp', highGust: 'highGust', highRainRate: 'highRainRate',
highDailyRain: 'highDailyRain', highUV: 'highUV', highSolar: 'highSolar',
highPressure: 'highPressure', lowPressure: 'lowPressure', highHumidity: 'highHumidity', lowHumidity: 'lowHumidity',
};
for (const [k, src] of Object.entries(flat)) {
out[k] = rec[src] ? rec[src].value : null;
out[k + 'Date'] = rec[src] ? (rec[src].ts || Date.parse(rec[src].day + 'T12:00:00Z')) : null;
}
out.totalReadings = days.reduce((a, [, v]) => a + (v.n || 0), 0);
out.firstDay = days[0][0];
out.lastDay = days[days.length - 1][0];
out.firstReading = Date.parse(out.firstDay + 'T12:00:00Z');
out.lastReading = Date.parse(out.lastDay + 'T12:00:00Z');
out.daysRecorded = days.length;
// Notable days
const dayRec = (field, kind) => { const t = top(field, kind); return t ? { value: t.value, day: t.day } : null; };
out.notable = {
hottestDay: dayRec('hi', 'max'),
coldestDay: dayRec('lo', 'min'),
coolestHigh: dayRec('hi', 'min'),
warmestLow: dayRec('lo', 'max'),
windiestDay: dayRec('gust', 'max'),
wettestDay: rec.highDailyRain,
};
// Per-period aggregation (month-of-year and calendar year)
const agg = (keyFn) => {
const g = {};
for (const [d, v] of days) {
const k = keyFn(d); const a = g[k] || (g[k] = { days: 0, rain: 0, hi: null, lo: null, gust: null, tsum: 0, tn: 0 });
a.days++; a.rain += v.rain || 0;
if (v.tn) { a.tsum += v.tsum; a.tn += v.tn; }
if (v.hi && (a.hi == null || v.hi[0] > a.hi)) a.hi = v.hi[0];
if (v.lo && (a.lo == null || v.lo[0] < a.lo)) a.lo = v.lo[0];
if (v.gust && (a.gust == null || v.gust[0] > a.gust)) a.gust = v.gust[0];
}
for (const a of Object.values(g)) {
a.rain = +a.rain.toFixed(2);
a.avg = a.tn ? +(a.tsum / a.tn).toFixed(1) : null;
delete a.tsum; delete a.tn;
}
return g;
};
const byYear = agg(d => d.slice(0, 4));
out.years = Object.entries(byYear).sort(([a], [b]) => a.localeCompare(b)).map(([year, a]) => ({ year, ...a }));
const byMonth = agg(d => d.slice(0, 7));
const ym = Object.entries(byMonth).sort(([a], [b]) => a.localeCompare(b)).map(([month, a]) => ({ month, ...a }));
out.months = ym;
out.wettestMonth = ym.reduce((b, m) => (!b || m.rain > b.rain ? m : b), null);
out.wettestYear = out.years.reduce((b, y) => (!b || y.rain > b.rain ? y : b), null);
// Month / year records (partial periods are flagged so "coldest month" can't be a 2-week stub unnoticed)
const dim = (ym) => new Date(+ym.slice(0, 4), +ym.slice(5, 7), 0).getDate();
const mk = (p, key, val, total) => ({ key, value: val, days: p.days, partial: p.days < total * 0.9 });
// preferComplete: averages / "driest" are only fair over whole periods, so rank complete ones first and
// fall back to partial ones only when no complete period exists yet. Totals and extremes stay valid when partial.
const pick = (list, keyName, valFn, dir, totalFn, preferComplete) => {
const rank = (onlyComplete) => {
let best = null;
for (const p of list) {
const v = valFn(p); if (v == null) continue;
const total = totalFn(p[keyName]);
if (onlyComplete && p.days < total * 0.9) continue;
if (!best || (dir === 'max' ? v > best.value : v < best.value)) best = mk(p, p[keyName], v, total);
}
return best;
};
return (preferComplete && rank(true)) || rank(false);
};
const mPick = (valFn, dir, preferComplete) => pick(ym, 'month', valFn, dir, dim, preferComplete);
const yPick = (valFn, dir, preferComplete) => pick(out.years, 'year', valFn, dir, () => 365, preferComplete);
out.periods = {
months: {
wettest: mPick(p => p.rain, 'max'), driest: mPick(p => p.rain, 'min', true),
hottest: mPick(p => p.avg, 'max', true), coldest: mPick(p => p.avg, 'min', true), windiest: mPick(p => p.gust, 'max'),
},
years: {
wettest: yPick(p => p.rain, 'max'), hottest: yPick(p => p.avg, 'max', true),
coldest: yPick(p => p.avg, 'min', true), windiest: yPick(p => p.gust, 'max'),
},
};
// Records for each calendar month across every year ("record July", "record January", …)
const cal = Array.from({ length: 12 }, () => ({ hi: null, lo: null, wettestDay: null, gust: null, wettestTotal: null, years: new Set() }));
const monthRain = {};
for (const [d, v] of days) {
const m = +d.slice(5, 7) - 1, c = cal[m];
c.years.add(d.slice(0, 4));
if (v.hi && (!c.hi || v.hi[0] > c.hi.value)) c.hi = { value: v.hi[0], day: d };
if (v.lo && (!c.lo || v.lo[0] < c.lo.value)) c.lo = { value: v.lo[0], day: d };
if (v.rain != null && v.rain > 0 && (!c.wettestDay || v.rain > c.wettestDay.value)) c.wettestDay = { value: v.rain, day: d };
if (v.gust && (!c.gust || v.gust[0] > c.gust.value)) c.gust = { value: v.gust[0], day: d };
monthRain[d.slice(0, 7)] = (monthRain[d.slice(0, 7)] || 0) + (v.rain || 0);
}
for (const [ymKey, total] of Object.entries(monthRain)) {
const c = cal[+ymKey.slice(5, 7) - 1];
if (total > 0 && (!c.wettestTotal || total > c.wettestTotal.value)) c.wettestTotal = { value: +total.toFixed(2), month: ymKey };
}
out.calendarMonths = cal.map((c, i) => ({ month: i + 1, years: c.years.size, hi: c.hi, lo: c.lo, wettestDay: c.wettestDay, gust: c.gust, wettestTotal: c.wettestTotal }));
return out;
}
try {
if (!Object.keys(recordsStore.days).length) console.log(`[records] built from ${rebuildRecordsFromFiles()} readings`);
else rebuildRecordsFromFiles();
} catch (e) { console.error('[records] startup rebuild error:', e.message); }
setInterval(saveRecordsStore, 30 * 1000).unref();
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { saveRecordsStore(); try { saveAlertDetails(); } catch {} process.exit(0); });
function loadStationReadings(rangeMs) {
const now = Date.now();
const cutoff = now - rangeMs;
const days = Math.ceil(rangeMs / 86400000) + 1;
const results = [];
const seen = new Set();
for (let i = 0; i < days; i++) {
const day = stationDateKey(now - i * 86400000);
const file = stationDayFile(day);
try {
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
for (const line of lines) {
try {
const obj = JSON.parse(line);
const ts = obj.dateutc || 0;
if (ts >= cutoff && !seen.has(ts)) { seen.add(ts); results.push(obj); }
} catch {}
}
} catch {}
}
return results.sort((a, b) => (a.dateutc || 0) - (b.dateutc || 0));
}
// ── VAPID web push ────────────────────────────────────────────────────────────
const VAPID_FILE = path.join(DATA_DIR, 'vapid.json');
let vapidKeys;
try {
vapidKeys = JSON.parse(fs.readFileSync(VAPID_FILE, 'utf8'));
} catch {
vapidKeys = webpush.generateVAPIDKeys();
fs.writeFileSync(VAPID_FILE, JSON.stringify(vapidKeys));
console.log('[vapid] Generated new keys. Public key:', vapidKeys.publicKey);
}
webpush.setVapidDetails('mailto:nate@necloud.us', vapidKeys.publicKey, vapidKeys.privateKey);
// ── Push subscription store ───────────────────────────────────────────────────
const SUBS_FILE = path.join(DATA_DIR, 'subscriptions.json');
let pushSubscriptions = [];
try {
if (fs.existsSync(SUBS_FILE)) pushSubscriptions = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8'));
} catch { pushSubscriptions = []; }
function saveSubs() {
try { fs.writeFileSync(SUBS_FILE, JSON.stringify(pushSubscriptions, null, 2)); } catch {}
}
function setCache(key, data) {
if (weatherCache.size >= WEATHER_CACHE_MAX_ENTRIES) {
const oldestKey = weatherCache.keys().next().value;
weatherCache.delete(oldestKey);
}
weatherCache.set(key, { data, at: Date.now() });
}
// Fetch with retry/timeout, shared by all upstream proxy calls.
async function fetchWithRetry(url, { attempts = 2, timeoutMs = 12000, retryDelayMs = 500, fetchOptions } = {}) {
let lastError = null;
for (let attempt = 1; attempt <= attempts; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const r = await fetch(url, { ...fetchOptions, signal: controller.signal });
clearTimeout(timeoutId);
if (!r.ok) throw new Error(`Upstream status ${r.status}`);
return r;
} catch (err) {
clearTimeout(timeoutId);
lastError = err;
if (attempt < attempts) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
}
}
}
throw lastError;
}
function isValidCoord(value, min, max) {
const n = Number(value);
return Number.isFinite(n) && n >= min && n <= max;
}
// 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.use(express.static(path.join(__dirname, 'public')));
// Prevent browser favicon requests from showing noisy 404s.
app.get('/favicon.ico', (_req, res) => {
res.status(204).end();
});
// 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/satellite tile configuration
app.get('/api/radar/times', async (req, res) => {
try {
const now = Math.floor(Date.now() / 1000);
// IEM NEXRAD tile service (US radar, public/free) with historical offsets for animation.
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,
};
});
// IEM GOES East cloud/satellite frames for animation.
const satOffsetsMin = [30, 25, 20, 15, 10, 5, 0];
const satelliteInfrared = satOffsetsMin.map(offset => {
const layer = offset === 0 ? 'goes_east' : `goes_east_m${String(offset).padStart(2, '0')}m`;
return {
time: now - (offset * 60),
url: `https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/${layer}/{z}/{x}/{y}.png`,
isForecast: false,
maxNativeZoom: 10,
};
});
res.json({
provider: 'iem-radar-sat',
radar: {
past: radarPast,
},
satellite: {
infrared: satelliteInfrared,
},
});
} 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');
res.sendFile(path.join(__dirname, 'public', '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');
res.sendFile(path.join(__dirname, 'public', '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');
res.sendFile(path.join(__dirname, 'public', '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) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
// Settings page
// Account page: everyone can change their own password; admins also manage users here
app.get('/dashboard/account', requireDashboardAuth, (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard-account.html'));
});
app.get('/dashboard/settings', requireDashboardAuth, requireAdmin, (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard-settings.html'));
});
// Downsample into ~`points` time buckets: averages for smooth series, true max/min for
// extremes, and a speed-weighted vector mean for wind direction.
const BUCKET_AVG = ['tempf', 'feelsLike', 'humidity', 'dewPoint', 'windspeedmph', 'baromrelin', 'baromabsin', 'uv', 'solarradiation', 'tempinf', 'humidityin'];
const BUCKET_MAX = ['windgustmph', 'rainratein', 'dailyrainin', 'maxdailygust'];
function bucketReadings(readings, points) {
const t0 = readings[0].dateutc, span = readings[readings.length - 1].dateutc - t0 || 1;
const size = span / points;
const buckets = new Map();
for (const r of readings) {
const i = Math.min(points - 1, Math.floor((r.dateutc - t0) / size));
let b = buckets.get(i);
if (!b) { b = { n: 0, tsum: 0, sums: {}, cnt: {}, max: {}, tmin: Infinity, tmax: -Infinity, vx: 0, vy: 0 }; buckets.set(i, b); }
b.n++; b.tsum += r.dateutc;
for (const k of BUCKET_AVG) if (r[k] != null) { b.sums[k] = (b.sums[k] || 0) + r[k]; b.cnt[k] = (b.cnt[k] || 0) + 1; }
for (const k of BUCKET_MAX) if (r[k] != null && (b.max[k] == null || r[k] > b.max[k])) b.max[k] = r[k];
if (r.tempf != null) { if (r.tempf < b.tmin) b.tmin = r.tempf; if (r.tempf > b.tmax) b.tmax = r.tempf; }
if (r.winddir != null) { const w = Math.max(r.windspeedmph || 0, 0.1) * Math.PI / 180; const a = r.winddir * Math.PI / 180; b.vx += Math.sin(a) * (r.windspeedmph || 0.1); b.vy += Math.cos(a) * (r.windspeedmph || 0.1); }
}
return [...buckets.entries()].sort(([a], [b]) => a - b).map(([, b]) => {
const o = { dateutc: Math.round(b.tsum / b.n), n: b.n };
for (const k of BUCKET_AVG) if (b.cnt[k]) o[k] = +(b.sums[k] / b.cnt[k]).toFixed(k === 'baromrelin' || k === 'baromabsin' ? 3 : 2);
for (const k of BUCKET_MAX) if (b.max[k] != null) o[k] = b.max[k];
if (b.tmin !== Infinity) { o.tempMin = b.tmin; o.tempMax = b.tmax; }
if (b.vx || b.vy) o.winddir = Math.round((Math.atan2(b.vx, b.vy) * 180 / Math.PI + 360) % 360);
return o;
});
}
// ── NWS reference station (official observations for a ZIP code) ──────────────
// Resolves the ZIP to its nearest NWS observation station, logs every observation it publishes
// (normalised to imperial units + the raw METAR), keeps hourly-forecast snapshots, and serves the
// data for the dashboard's overlay. Base URLs are overridable so it can be tested offline.
const NWS_BASE = process.env.NWS_BASE || 'https://api.weather.gov';
const ZIP_API = process.env.ZIP_API || 'https://api.zippopotam.us/us';
const NWS_DIR = path.join(DATA_DIR, 'nws');
const NWS_FC_DIR = path.join(NWS_DIR, 'forecast');
fs.mkdirSync(NWS_FC_DIR, { recursive: true });
const NWS_HEADERS = { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } };
const NWS_ZIP_FALLBACK = { '24550': { lat: 37.22, lon: -79.34, place: 'Evington, VA' } }; // used only if the ZIP lookup is unreachable
const nwsState = { running: false, lastError: null, lastPollAt: 0, lastForecastAt: 0, lastObsTs: 0, obsCount: 0, firstObsTs: null };
const nwsSaved = () => dashAuth?.nws || {};
function nwsSave(patch) {
dashAuth = { ...(dashAuth || {}), nws: { ...nwsSaved(), ...patch } };
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
}
const haversineKm = (a, b, c, d) => { const R = 6371, r = Math.PI / 180, dLat = (c - a) * r, dLon = (d - b) * r; const x = Math.sin(dLat / 2) ** 2 + Math.cos(a * r) * Math.cos(c * r) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(x)); };
async function nwsResolve(force = false) {
const cfg = nwsSaved();
const zip = cfg.zip || '24550';
const res = cfg.resolved;
if (!force && res && res.zip === zip && Date.now() - res.resolvedAt < 30 * 86400000) return res;
let lat = cfg.lat, lon = cfg.lon, place = null, approx = false;
if (lat == null || lon == null) {
try {
const r = await fetchWithRetry(`${ZIP_API}/${zip}`, { attempts: 2, timeoutMs: 10000 });
const z = await r.json();
const pl = z.places?.[0];
lat = parseFloat(pl.latitude); lon = parseFloat(pl.longitude);
place = `${pl['place name']}, ${pl['state abbreviation']}`;
} catch (e) {
const fb = NWS_ZIP_FALLBACK[zip];
if (!fb) throw new Error(`Could not look up ZIP ${zip}`);
lat = fb.lat; lon = fb.lon; place = fb.place; approx = true;
}
}
const pr = await fetchWithRetry(`${NWS_BASE}/points/${lat.toFixed(4)},${lon.toFixed(4)}`, { attempts: 2, timeoutMs: 12000, fetchOptions: NWS_HEADERS });
const pt = (await pr.json()).properties;
const sr = await fetchWithRetry(pt.observationStations, { attempts: 2, timeoutMs: 12000, fetchOptions: NWS_HEADERS });
const st = (await sr.json()).features?.[0];
if (!st) throw new Error('No observation station near this ZIP');
const [sLon, sLat] = st.geometry?.coordinates || [null, null];
const rl = pt.relativeLocation?.properties;
const resolved = {
zip, lat, lon, approx, place: place || (rl ? `${rl.city}, ${rl.state}` : zip),
stationId: st.properties.stationIdentifier, stationName: st.properties.name, stationLat: sLat, stationLon: sLon,
distanceMi: sLat != null ? +(haversineKm(lat, lon, sLat, sLon) * 0.621371).toFixed(1) : null,
forecastHourly: pt.forecastHourly, gridId: pt.gridId, timeZone: pt.timeZone, resolvedAt: Date.now(),
};
nwsSave({ zip, resolved });
console.log(`[nws] ZIP ${zip} → ${resolved.stationId} (${resolved.stationName}), ${resolved.distanceMi} mi`);
return resolved;
}
const nwsQ = (q, kind) => {
if (!q || q.value == null || !Number.isFinite(q.value)) return null;
const unit = String(q.unitCode || '').split(':').pop(), v = q.value; // e.g. "wmoUnit:km_h-1" -> "km_h-1"
switch (kind) {
case 'temp': return unit === 'degC' ? v * 9 / 5 + 32 : unit === 'K' ? (v - 273.15) * 9 / 5 + 32 : v;
case 'speed': return unit === 'km_h-1' ? v * 0.621371 : unit === 'm_s-1' ? v * 2.23694 : unit === 'kt' ? v * 1.15078 : v;
case 'press': return unit === 'Pa' ? v * 0.00029530 : unit === 'hPa' ? v * 0.02953 : v;
case 'dist': return unit === 'm' ? v * 0.000621371 : unit === 'km' ? v * 0.621371 : v;
case 'len': return unit === 'mm' ? v / 25.4 : unit === 'm' ? v * 39.3701 : v;
default: return v;
}
};
const r1 = (v, d = 1) => (v == null ? null : +v.toFixed(d));
function normalizeNwsObs(f) {
const p = f.properties || {};
const ts = Date.parse(p.timestamp);
if (!Number.isFinite(ts)) return null;
return {
dateutc: ts, ts, station: (p.station || '').split('/').pop() || null,
tempf: r1(nwsQ(p.temperature, 'temp')), dewPoint: r1(nwsQ(p.dewpoint, 'temp')), humidity: r1(nwsQ(p.relativeHumidity)),
windspeedmph: r1(nwsQ(p.windSpeed, 'speed')), windgustmph: r1(nwsQ(p.windGust, 'speed')), winddir: r1(nwsQ(p.windDirection), 0),
baromrelin: r1(nwsQ(p.seaLevelPressure, 'press'), 3), baromabsin: r1(nwsQ(p.barometricPressure, 'press'), 3),
visibilityMi: r1(nwsQ(p.visibility, 'dist'), 1),
rain1hIn: r1(nwsQ(p.precipitationLastHour, 'len'), 2), rain3hIn: r1(nwsQ(p.precipitationLast3Hours, 'len'), 2), rain6hIn: r1(nwsQ(p.precipitationLast6Hours, 'len'), 2),
heatIndexF: r1(nwsQ(p.heatIndex, 'temp')), windChillF: r1(nwsQ(p.windChill, 'temp')),
max24F: r1(nwsQ(p.maxTemperatureLast24Hours, 'temp')), min24F: r1(nwsQ(p.minTemperatureLast24Hours, 'temp')),
text: p.textDescription || null,
clouds: (p.cloudLayers || []).map(c => ({ amount: c.amount, baseFt: c.base?.value != null ? Math.round(c.base.value * 3.28084) : null })),
presentWeather: (p.presentWeather || []).map(w => w.weather || w.rawString).filter(Boolean),
raw: p.rawMessage || null,
};
}
const nwsSeen = new Map(); // day -> Set(ts)
function nwsDaySet(day) {
let set = nwsSeen.get(day);
if (set) return set;
set = new Set();
try { for (const l of fs.readFileSync(path.join(NWS_DIR, `${day}.jsonl`), 'utf8').split('\n')) if (l) { try { set.add(JSON.parse(l).ts); } catch {} } } catch {}
nwsSeen.set(day, set);
return set;
}
function nwsSaveObs(rec) {
const day = new Date(rec.ts).toISOString().slice(0, 10);
const set = nwsDaySet(day);
if (set.has(rec.ts)) return false;
fs.appendFileSync(path.join(NWS_DIR, `${day}.jsonl`), JSON.stringify(rec) + '\n');
set.add(rec.ts);
nwsState.obsCount++;
if (rec.ts > nwsState.lastObsTs) nwsState.lastObsTs = rec.ts;
if (!nwsState.firstObsTs || rec.ts < nwsState.firstObsTs) nwsState.firstObsTs = rec.ts;
return true;
}
async function nwsFetchObservations(res, limit) {
const r = await fetchWithRetry(`${NWS_BASE}/stations/${res.stationId}/observations?limit=${limit}`, { attempts: 2, timeoutMs: 15000, fetchOptions: NWS_HEADERS });
const feats = (await r.json()).features || [];
let added = 0;
for (const f of feats.reverse()) { const rec = normalizeNwsObs(f); if (rec && nwsSaveObs(rec)) added++; }
return { seen: feats.length, added };
}
async function nwsSnapshotForecast(res) {
if (!res.forecastHourly) return;
const r = await fetchWithRetry(res.forecastHourly, { attempts: 2, timeoutMs: 15000, fetchOptions: NWS_HEADERS });
const p = (await r.json()).properties || {};
const issued = Date.parse(p.updateTime || p.generatedAt) || Date.now();
const day = new Date(issued).toISOString().slice(0, 10);
const file = path.join(NWS_FC_DIR, `${day}.jsonl`);
try { if (fs.readFileSync(file, 'utf8').split('\n').some(l => l && JSON.parse(l).issued === issued)) return; } catch {}
const periods = (p.periods || []).slice(0, 48).map(x => ({
start: Date.parse(x.startTime), tempf: x.temperatureUnit === 'C' ? x.temperature * 9 / 5 + 32 : x.temperature,
pop: x.probabilityOfPrecipitation?.value ?? null, wind: x.windSpeed, windDir: x.windDirection,
humidity: x.relativeHumidity?.value ?? null, dewC: x.dewpoint?.value ?? null, short: x.shortForecast,
}));
fs.appendFileSync(file, JSON.stringify({ ts: Date.now(), issued, station: res.stationId, zip: res.zip, periods }) + '\n');
}
async function nwsPoll() {
if (nwsState.running) return;
nwsState.running = true;
try {
const first = !nwsSaved().backfilled;
const res = await nwsResolve();
const out = await nwsFetchObservations(res, first ? 500 : 15); // first run: everything NWS still keeps (~7 days)
if (first && out.seen) nwsSave({ backfilled: Date.now() });
if (Date.now() - nwsState.lastForecastAt > 3 * 3600000) { await nwsSnapshotForecast(res); nwsState.lastForecastAt = Date.now(); }
nwsState.lastError = null;
nwsState.lastPollAt = Date.now();
if (out.added) console.log(`[nws] logged ${out.added} observation(s) from ${res.stationId}`);
} catch (e) {
nwsState.lastError = e.message;
console.error('[nws] poll error:', e.message);
}
nwsState.running = false;
}
function loadNwsReadings(rangeMs) {
const now = Date.now(), cutoff = now - rangeMs;
const days = Math.min(Math.ceil(rangeMs / 86400000) + 1, 4000);
const out = [];
for (let i = 0; i < days; i++) {
const day = stationDateKey(now - i * 86400000);
try {
for (const l of fs.readFileSync(path.join(NWS_DIR, `${day}.jsonl`), 'utf8').split('\n')) {
if (!l) continue;
try { const o = JSON.parse(l); if (o.ts >= cutoff) { delete o.raw; out.push(o); } } catch {}
}
} catch {}
}
return out.sort((a, b) => a.ts - b.ts);
}
// Index what's already on disk so the status shows the right totals after a restart
(function nwsIndex() {
try {
for (const f of fs.readdirSync(NWS_DIR).filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)).sort()) {
for (const l of fs.readFileSync(path.join(NWS_DIR, f), 'utf8').split('\n')) {
if (!l) continue;
try { const t = JSON.parse(l).ts; nwsState.obsCount++; if (t > nwsState.lastObsTs) nwsState.lastObsTs = t; if (!nwsState.firstObsTs || t < nwsState.firstObsTs) nwsState.firstObsTs = t; } catch {}
}
}
} catch {}
})();
setTimeout(nwsPoll, 20 * 1000).unref();
setInterval(nwsPoll, 10 * 60 * 1000).unref();
// Station history API
app.get('/api/station/history', requireDashboardAuth, (req, res) => {
const rangeMap = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '2d': 172800000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
const range = req.query.range || '24h';
const points = parseInt(req.query.points);
if (range === 'all') {
// Everything on disk, always bucketed (default 720 points) so the payload stays small
const first = firstStationDay();
if (!first) return res.json([]);
const rangeAll = Date.now() - Date.parse(first + 'T00:00:00Z') + 2 * 86400000;
const pts = points >= 50 ? points : 720;
const key = `${first}|${pts}`;
if (allHistoryCache.key === key && Date.now() - allHistoryCache.at < 60000) return res.json(allHistoryCache.data);
const readingsAll = loadStationReadings(rangeAll);
const data = readingsAll.length > pts ? bucketReadings(readingsAll, pts) : readingsAll;
allHistoryCache = { key, at: Date.now(), data };
return res.json(data);
}
const rangeMs = rangeMap[range] || rangeMap['24h'];
const readings = loadStationReadings(rangeMs);
if (points >= 50 && readings.length > points) return res.json(bucketReadings(readings, points));
res.json(readings);
});
// Earliest day with saved readings (YYYY-MM-DD, UTC file name)
let allHistoryCache = { key: null, at: 0, data: null };
function firstStationDay() {
try {
const files = fs.readdirSync(STATION_DIR).filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)).sort();
return files.length ? files[0].slice(0, 10) : null;
} catch { return null; }
}
// ── NWS reference data API ───────────────────────────────────────────────────
function nwsStatus() {
const c = nwsSaved();
return { zip: c.zip || '24550', resolved: c.resolved || null, observations: nwsState.obsCount, firstObservation: nwsState.firstObsTs, lastObservation: nwsState.lastObsTs || null, lastPoll: nwsState.lastPollAt || null, lastError: nwsState.lastError };
}
app.get('/api/nws/config', requireDashboardAuth, (_req, res) => res.json(nwsStatus()));
app.post('/api/nws/config', requireDashboardAuth, requireAdmin, async (req, res) => {
const zip = String(req.body?.zip || '').trim();
if (!/^\d{5}$/.test(zip)) return res.status(400).json({ error: 'Enter a 5-digit US ZIP code' });
nwsSave({ zip, resolved: null, lat: undefined, lon: undefined, backfilled: undefined });
nwsState.lastObsTs = 0; nwsState.lastForecastAt = 0;
try { await nwsResolve(true); } catch (e) { return res.status(502).json({ error: `Could not set up that ZIP: ${e.message}` }); }
nwsPoll();
res.json(nwsStatus());
});
app.get('/api/nws/latest', requireDashboardAuth, (_req, res) => {
const recent = loadNwsReadings(6 * 3600000);
const latest = recent[recent.length - 1] || null;
let raw = null;
if (latest) { try { const day = new Date(latest.ts).toISOString().slice(0, 10); for (const l of fs.readFileSync(path.join(NWS_DIR, `${day}.jsonl`), 'utf8').split('\n')) { if (l && JSON.parse(l).ts === latest.ts) raw = JSON.parse(l).raw; } } catch {} }
res.json({ ...nwsStatus(), latest: latest ? { ...latest, raw } : null });
});
app.get('/api/nws/history', requireDashboardAuth, (req, res) => {
const range = String(req.query.range || '24h');
const map = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '2d': 172800000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
let ms = map[range];
if (range === 'all') ms = 4000 * 86400000;
res.json(loadNwsReadings(ms || map['24h']));
});
// Latest station reading
app.get('/api/station/latest', requireDashboardAuth, async (req, res) => {
if (!ambientServerConfig.apiKey || !ambientServerConfig.appKey) {
return res.status(503).json({ error: 'Station not configured' });
}
const cached = ambientCaches.get(ambientServerConfig.apiKey);
if (cached) return res.json(cached.data?.[0]?.lastData || null);
res.json(null);
});
// ── Ambient historical backfill ───────────────────────────────────────────────
let backfillState = { running: false, done: 0, total: 0, inserted: 0, error: null };
async function runBackfill(daysBack) {
const apiKey = ambientServerConfig.apiKey;
const appKey = ambientServerConfig.appKey;
if (!apiKey || !appKey) {
backfillState = { running: false, done: 0, total: 0, inserted: 0, error: 'No API keys configured' };
return;
}
// Get device MAC address
let mac;
try {
const r = await fetchWithRetry(
`https://rt.ambientweather.net/v1/devices?apiKey=${encodeURIComponent(apiKey)}&applicationKey=${encodeURIComponent(appKey)}`,
{ attempts: 3, timeoutMs: 15000 }
);
const devices = await r.json();
mac = devices?.[0]?.macAddress;
} catch (e) {
backfillState = { running: false, done: 0, total: 0, inserted: 0, error: `Device fetch failed: ${e.message}` };
return;
}
if (!mac) {
backfillState = { running: false, done: 0, total: 0, inserted: 0, error: 'No device found for these keys' };
return;
}
const cutoff = Date.now() - daysBack * 86400000;
backfillState = { running: true, done: 0, total: daysBack, inserted: 0, error: null };
let endDate = Date.now();
let inserted = 0;
while (endDate > cutoff) {
await new Promise(r => setTimeout(r, 1100)); // respect 1 req/sec rate limit
try {
const url = `https://rt.ambientweather.net/v1/devices/${encodeURIComponent(mac)}?apiKey=${encodeURIComponent(apiKey)}&applicationKey=${encodeURIComponent(appKey)}&limit=288&endDate=${endDate}`;
const r = await fetchWithRetry(url, { attempts: 2, timeoutMs: 15000 });
const records = await r.json();
if (!Array.isArray(records) || records.length === 0) break;
let oldest = Infinity;
for (const rec of records) {
if (!rec.dateutc) continue;
if (rec.dateutc < oldest) oldest = rec.dateutc;
if (rec.dateutc < cutoff) continue;
if (saveStationReading(rec)) inserted++;
}
backfillState.done = Math.min(daysBack, Math.ceil((Date.now() - oldest) / 86400000));
backfillState.inserted = inserted;
if (records.length < 288 || oldest <= cutoff) break;
endDate = oldest - 1;
} catch (e) {
backfillState.error = e.message;
break;
}
}
backfillState.running = false;
backfillState.done = daysBack;
backfillState.inserted = inserted;
console.log(`[backfill] complete — inserted ${inserted} records`);
}
app.post('/api/station/backfill', requireDashboardAuth, requireAdmin, (req, res) => {
if (backfillState.running) return res.json({ ok: false, error: 'Backfill already running' });
const days = Math.min(Math.max(parseInt(req.body?.days || '30'), 1), 365);
res.json({ ok: true, days });
runBackfill(days).catch(e => {
backfillState.error = e.message;
backfillState.running = false;
});
});
app.get('/api/station/backfill/status', requireDashboardAuth, (_req, res) => {
res.json(backfillState);
});
// ── Station analytics ─────────────────────────────────────────────────────────
// Records — derived from the persistent per-day store (data/records.json)
app.get('/api/station/records', requireDashboardAuth, (_req, res) => {
res.json(deriveRecords());
});
// Public, minimal station context for the weather app (no raw readings): yesterday / today
// extremes and the station's own average high/low for this date from earlier years.
app.get('/api/station/summary', (_req, res) => {
const days = recordsStore.days;
const fmtDay = (ts) => new Intl.DateTimeFormat('en-CA', { timeZone: stationTzFallback }).format(new Date(ts));
const todayKey = fmtDay(Date.now());
const yKey = fmtDay(Date.now() - 86400000);
const pick = (k) => {
const d = days[k];
return d ? { day: k, hi: d.hi?.[0] ?? null, lo: d.lo?.[0] ?? null, rain: d.rain ?? null, gust: d.gust?.[0] ?? null } : null;
};
// Normals: same calendar date ±3 days in earlier years (needs a prior year of data)
const [ty, tm, td] = todayKey.split('-').map(Number);
const his = [], los = [];
for (const [k, d] of Object.entries(days)) {
const [y, m, dd] = k.split('-').map(Number);
if (y >= ty) continue;
const diff = Math.abs(Date.UTC(2001, m - 1, dd) - Date.UTC(2001, tm - 1, td)) / 86400000;
if (Math.min(diff, 365 - diff) <= 3) { if (d.hi) his.push(d.hi[0]); if (d.lo) los.push(d.lo[0]); }
}
const avg = (a) => (a.length >= 3 ? +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(1) : null);
res.json({ today: pick(todayKey), yesterday: pick(yKey), normal: { hi: avg(his), lo: avg(los), samples: his.length }, daysRecorded: Object.keys(days).length });
});
// Per-day summaries (extremes, average temp, rain, wind-direction histogram) for charts/insights
app.get('/api/station/daily', requireDashboardAuth, (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.days) || 0, 0), 5000);
let entries = Object.entries(recordsStore.days).sort(([a], [b]) => a.localeCompare(b));
if (limit) entries = entries.slice(-limit);
res.json(entries.map(([d, v]) => ({
d, n: v.n || 0,
hi: v.hi?.[0] ?? null, hiT: v.hi?.[1] ?? null, lo: v.lo?.[0] ?? null, loT: v.lo?.[1] ?? null,
avg: v.tn ? +(v.tsum / v.tn).toFixed(1) : null,
gust: v.gust?.[0] ?? null, gustT: v.gust?.[1] ?? null,
rain: v.rain ?? null, rate: v.rate?.[0] ?? null,
uv: v.uv?.[0] ?? null, solar: v.solar?.[0] ?? null,
pmax: v.pmax?.[0] ?? null, pmin: v.pmin?.[0] ?? null,
hmax: v.hmax?.[0] ?? null, hmin: v.hmin?.[0] ?? null,
wd: v.wd || null,
})));
});
// Re-scan raw readings into the records store (merge only; never lowers a record)
app.post('/api/station/records/rebuild', requireDashboardAuth, requireAdmin, (_req, res) => {
const scanned = rebuildRecordsFromFiles();
res.json({ ok: true, scanned });
});
// Full backup: every saved reading (deduped, oldest first) as newline-delimited JSON
app.get('/api/station/backup.jsonl', requireDashboardAuth, (_req, res) => {
let files;
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { files = []; }
res.setHeader('Content-Type', 'application/x-ndjson');
res.setHeader('Content-Disposition', `attachment; filename="station-backup-${new Date().toISOString().slice(0, 10)}.jsonl"`);
for (const f of files) {
try {
const seen = new Set();
const rows = [];
for (const l of fs.readFileSync(path.join(STATION_DIR, f), 'utf8').split('\n')) {
if (!l) continue;
try { const t = JSON.parse(l).dateutc; if (seen.has(t)) continue; seen.add(t); } catch {}
rows.push(l);
}
if (rows.length) res.write(rows.join('\n') + '\n');
} catch {}
}
res.end();
});
// Records store (per-day extremes) as JSON
app.get('/api/station/records-backup.json', requireDashboardAuth, (_req, res) => {
res.setHeader('Content-Disposition', `attachment; filename="station-records-${new Date().toISOString().slice(0, 10)}.json"`);
res.json(recordsStore);
});
app.get('/api/station/rain-totals', requireDashboardAuth, (_req, res) => {
const now = Date.now();
const ytdCutoff = new Date(new Date().getFullYear(), 0, 1).getTime();
function sumDailyRain(readings) {
const byDay = {};
for (const r of readings) {
if (r.dailyrainin == null || !r.dateutc) continue;
const d = stationLocalDay(r);
if (byDay[d] == null || r.dailyrainin > byDay[d]) byDay[d] = r.dailyrainin;
}
return +Object.values(byDay).reduce((a, b) => a + b, 0).toFixed(2);
}
res.json({
rain7d: sumDailyRain(loadStationReadings(7 * 86400000)),
rain30d: sumDailyRain(loadStationReadings(30 * 86400000)),
rainYTD: sumDailyRain(loadStationReadings(now - ytdCutoff)),
});
});
// Heating/cooling degree days
app.get('/api/station/degree-days', requireDashboardAuth, (req, res) => {
const base = parseFloat(req.query.base || '65');
const rangeMs = Math.min(parseInt(req.query.rangeMs || String(30 * 86400000)), 365 * 86400000);
const readings = loadStationReadings(rangeMs);
const byDay = {};
for (const r of readings) {
if (r.tempf == null || !r.dateutc) continue;
const d = stationLocalDay(r);
if (!byDay[d]) byDay[d] = [];
byDay[d].push(r.tempf);
}
let hdd = 0, cdd = 0;
for (const temps of Object.values(byDay)) {
const avg = temps.reduce((a, b) => a + b, 0) / temps.length;
hdd += Math.max(0, base - avg);
cdd += Math.max(0, avg - base);
}
res.json({ hdd: +hdd.toFixed(1), cdd: +cdd.toFixed(1), base, days: Object.keys(byDay).length });
});
// CSV export
const RANGE_MS_MAP = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
app.get('/api/station/export.csv', requireDashboardAuth, (req, res) => {
const range = req.query.range || '30d';
const readings = loadStationReadings(RANGE_MS_MAP[range] || RANGE_MS_MAP['30d']);
const cols = ['date','tempf','feelsLike','humidity','dewPoint','windspeedmph','windgustmph','winddir','baromrelin','baromabsin','rainratein','dailyrainin','weeklyrainin','monthlyrainin','uv','solarradiation','battout'];
const header = cols.join(',');
const rows = readings.map(r => cols.map(c => {
if (c === 'date') return new Date(r.dateutc).toISOString();
return r[c] == null ? '' : r[c];
}).join(','));
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="station-${range}-${new Date().toISOString().slice(0,10)}.csv"`);
res.send([header, ...rows].join('\n'));
});
// Year-over-year: current range + same range 1 year prior (timestamps shifted forward 1yr for chart overlay)
app.get('/api/station/yoy', requireDashboardAuth, (req, res) => {
const rangeMs = RANGE_MS_MAP[req.query.range] || RANGE_MS_MAP['1y'];
const YEAR_MS = 365.25 * 86400000;
const current = loadStationReadings(rangeMs);
const priorEnd = Date.now() - YEAR_MS;
const prior = loadStationReadings(rangeMs + YEAR_MS).filter(r => r.dateutc >= priorEnd - rangeMs && r.dateutc <= priorEnd);
const priorShifted = prior.map(r => ({ dateutc: Math.round(r.dateutc + YEAR_MS), tempf: r.tempf, humidity: r.humidity, windspeedmph: r.windspeedmph, baromrelin: r.baromrelin }));
res.json({ current: current.map(r => ({ dateutc: r.dateutc, tempf: r.tempf, humidity: r.humidity, windspeedmph: r.windspeedmph, baromrelin: r.baromrelin })), prior: priorShifted });
});
// Anomaly — hourly baseline from all historical data
app.get('/api/station/anomaly', requireDashboardAuth, (_req, res) => {
const all = loadStationReadings(365 * 86400000);
if (all.length < 200) return res.json({ available: false });
const byHour = Array.from({ length: 24 }, () => []);
for (const r of all) {
if (r.tempf == null) continue;
byHour[new Date(r.dateutc).getHours()].push(r.tempf);
}
const hourStats = byHour.map(temps => {
if (temps.length < 10) return null;
const mean = temps.reduce((a, b) => a + b) / temps.length;
const stddev = Math.sqrt(temps.reduce((a, b) => a + (b - mean) ** 2, 0) / temps.length);
return { mean: +mean.toFixed(1), stddev: +stddev.toFixed(1) };
});
const last = all[all.length - 1];
const h = new Date(last.dateutc).getHours();
const stat = hourStats[h];
const z = (stat && stat.stddev > 0) ? (last.tempf - stat.mean) / stat.stddev : 0;
res.json({ available: true, hourStats, anomaly: z > 2 ? 'above' : z < -2 ? 'below' : 'normal', currentTemp: last.tempf, currentHour: h, mean: stat?.mean, stddev: stat?.stddev });
});
// The old standalone /conditions page now lives in the app as the Station tab
app.get('/conditions', (_req, res) => res.redirect(301, '/?tab=station'));
// ── Dashboard management APIs ─────────────────────────────────────────────────
// Who am I
app.get('/api/dashboard/me', requireDashboardAuth, (req, res) => {
res.json({ username: req.dashUser.username, role: req.dashUser.role });
});
// Change own password (signs out other sessions, keeps this one)
app.post('/api/dashboard/change-password', requireDashboardAuth, (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword || newPassword.length < PASSWORD_MIN)
return res.status(400).json({ error: `New password must be at least ${PASSWORD_MIN} characters` });
const user = req.dashUser;
const key = 'pw|' + user.username.toLowerCase();
if (isLockedOut(key)) return res.status(429).json({ error: 'Too many attempts — try again in a few minutes' });
if (!checkPassword(user, currentPassword)) { noteLoginFailure(key); return res.status(403).json({ error: 'Current password incorrect' }); }
loginAttempts.delete(key);
setPassword(user, newPassword);
saveUsers();
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
res.json({ ok: true });
});
// ── User management (admin only) ──────────────────────────────────────────────
app.get('/api/users', requireDashboardAuth, requireAdmin, (_req, res) => {
res.json(usersStore.users.map(publicUser));
});
app.post('/api/users', requireDashboardAuth, requireAdmin, (req, res) => {
const { username, password, role } = req.body || {};
if (!USERNAME_RE.test(String(username || ''))) return res.status(400).json({ error: 'Username must be 3–32 characters: letters, numbers, . _ -' });
if (!password || password.length < PASSWORD_MIN) return res.status(400).json({ error: `Password must be at least ${PASSWORD_MIN} characters` });
if (!ROLES.includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
if (findUser(username)) return res.status(409).json({ error: 'That username is already taken' });
const user = { username, role, epoch: 0, createdAt: Date.now() };
setPassword(user, password); user.epoch = 0;
usersStore.users.push(user);
saveUsers();
console.log(`[auth] ${req.dashUser.username} created ${role} "${username}"`);
res.status(201).json(publicUser(user));
});
app.patch('/api/users/:username', requireDashboardAuth, requireAdmin, (req, res) => {
const user = findUser(req.params.username);
if (!user) return res.status(404).json({ error: 'User not found' });
const { role, password, disabled } = req.body || {};
const isSelf = user.username === req.dashUser.username;
const losesAdmin = (role && role !== 'admin') || disabled === true;
if (user.role === 'admin' && !user.disabled && losesAdmin) {
if (isSelf) return res.status(400).json({ error: "You can't demote or disable your own account" });
if (activeAdmins().length <= 1) return res.status(400).json({ error: 'There must be at least one active admin' });
}
if (role !== undefined) {
if (!ROLES.includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
user.role = role;
}
if (disabled !== undefined) {
user.disabled = !!disabled;
if (user.disabled) user.epoch = (user.epoch || 0) + 1;
}
if (password !== undefined) {
if (typeof password !== 'string' || password.length < PASSWORD_MIN) return res.status(400).json({ error: `Password must be at least ${PASSWORD_MIN} characters` });
setPassword(user, password);
clearAttemptsFor(user.username);
if (isSelf) res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch)));
}
saveUsers();
console.log(`[auth] ${req.dashUser.username} updated "${user.username}"`);
res.json(publicUser(user));
});
app.delete('/api/users/:username', requireDashboardAuth, requireAdmin, (req, res) => {
const user = findUser(req.params.username);
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.username === req.dashUser.username) return res.status(400).json({ error: "You can't delete your own account" });
if (user.role === 'admin' && !user.disabled && activeAdmins().length <= 1) return res.status(400).json({ error: 'There must be at least one active admin' });
usersStore.users = usersStore.users.filter(u => u !== user);
saveUsers();
console.log(`[auth] ${req.dashUser.username} deleted "${user.username}"`);
res.json({ ok: true });
});
// Station name
app.get('/api/dashboard/station-name', requireDashboardAuth, (_req, res) => {
res.json({ name: dashAuth?.stationName || 'Genesis 8:22' });
});
app.post('/api/dashboard/station-name', requireDashboardAuth, requireAdmin, (req, res) => {
const { name } = req.body || {};
if (!name || typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'Invalid name' });
dashAuth = { ...dashAuth, stationName: name.trim().slice(0, 60) };
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
res.json({ ok: true, name: dashAuth.stationName });
});
// Monthly summary — avg temp, total rain, high temp, high gust per month
app.get('/api/station/monthly-summary', requireDashboardAuth, (_req, res) => {
let files;
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return res.json([]); }
const months = {};
for (const file of files) {
try {
const lines = fs.readFileSync(path.join(STATION_DIR, file), 'utf8').split('\n').filter(Boolean);
for (const line of lines) {
try {
const r = JSON.parse(line);
if (!r.dateutc) continue;
const day = stationLocalDay(r);
const key = day.slice(0, 7); // YYYY-MM (local)
if (!months[key]) months[key] = { temps: [], highTemp: null, highGust: null, dailyRainMax: {} };
const m = months[key];
if (r.tempf != null) {
m.temps.push(r.tempf);
if (m.highTemp == null || r.tempf > m.highTemp) m.highTemp = r.tempf;
}
if (r.windgustmph != null && (m.highGust == null || r.windgustmph > m.highGust)) m.highGust = r.windgustmph;
if (r.dailyrainin != null) {
if (m.dailyRainMax[day] == null || r.dailyrainin > m.dailyRainMax[day]) m.dailyRainMax[day] = r.dailyrainin;
}
} catch {}
}
} catch {}
}
const result = Object.entries(months).sort(([a], [b]) => a.localeCompare(b)).map(([key, m]) => ({
month: key,
avgTemp: m.temps.length ? +(m.temps.reduce((a, b) => a + b, 0) / m.temps.length).toFixed(1) : null,
highTemp: m.highTemp != null ? +m.highTemp.toFixed(1) : null,
highGust: m.highGust != null ? +m.highGust.toFixed(0) : null,
totalRain: +Object.values(m.dailyRainMax).reduce((a, b) => a + b, 0).toFixed(2),
readings: m.temps.length,
}));
res.json(result);
});
// Subscriptions list (for reading thresholds in dashboard)
app.get('/api/subscriptions', requireDashboardAuth, (_req, res) => {
res.json(pushSubscriptions.map(s => ({ ntfyTopic: s.ntfyTopic, thresholds: s.thresholds || {}, hasWebPush: !!s.subscription })));
});
// Update thresholds on all subscriptions at once
app.post('/api/subscriptions/thresholds', requireDashboardAuth, requireAdmin, (req, res) => {
const { thresholds } = req.body || {};
if (!thresholds || typeof thresholds !== 'object') return res.status(400).json({ error: 'Invalid' });
for (const sub of pushSubscriptions) sub.thresholds = { ...(sub.thresholds || {}), ...thresholds };
saveSubs();
res.json({ ok: true, updated: pushSubscriptions.length });
});
// Station location (for dashboard alerts)
app.get('/api/dashboard/location', requireDashboardAuth, (_req, res) => {
if (dashAuth?.stationLat != null)
return res.json({ lat: dashAuth.stationLat, lon: dashAuth.stationLon });
if (pushSubscriptions.length)
return res.json({ lat: pushSubscriptions[0].lat, lon: pushSubscriptions[0].lon, fromSubscription: true });
res.json(null);
});
app.post('/api/dashboard/location', requireDashboardAuth, requireAdmin, (req, res) => {
const { lat, lon } = req.body || {};
if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180))
return res.status(400).json({ error: 'Invalid coordinates' });
dashAuth = { ...dashAuth, stationLat: +lat, stationLon: +lon };
try { fs.writeFileSync(DASH_AUTH_FILE, JSON.stringify(dashAuth)); } catch {}
dashAlertCache.clear();
res.json({ ok: true });
});
// NWS alerts for the station location (5-min cache)
const dashAlertCache = new Map();
app.get('/api/dashboard/alerts', requireDashboardAuth, async (_req, res) => {
let lat = dashAuth?.stationLat;
let lon = dashAuth?.stationLon;
if (lat == null && pushSubscriptions.length) { lat = pushSubscriptions[0].lat; lon = pushSubscriptions[0].lon; }
if (lat == null) return res.json({ features: [], noLocation: true });
const cacheKey = `${lat},${lon}`;
const cached = dashAlertCache.get(cacheKey);
if (cached && Date.now() - cached.at < 5 * 60 * 1000) return res.json(cached.data);
try {
const r = await fetchWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
attempts: 1, timeoutMs: 12000,
fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } },
});
const data = await r.json();
dashAlertCache.set(cacheKey, { data, at: Date.now() });
res.json(data);
} catch { res.json({ features: [] }); }
});
// Consecutive dry/wet streak (from daily rain totals)
app.get('/api/station/streak', requireDashboardAuth, (_req, res) => {
const readings = loadStationReadings(60 * 86400000);
const byDay = {};
for (const r of readings) {
if (r.dailyrainin == null || !r.dateutc) continue;
const d = stationLocalDay(r);
if (byDay[d] == null || r.dailyrainin > byDay[d]) byDay[d] = r.dailyrainin;
}
const days = Object.keys(byDay).sort().reverse();
if (!days.length) return res.json({ type: null, count: 0 });
const latestRain = byDay[days[0]];
const streakType = latestRain >= 0.01 ? 'wet' : 'dry';
let count = 0;
for (const d of days) {
const rain = byDay[d];
if (streakType === 'wet' ? rain >= 0.01 : rain < 0.01) count++;
else break;
}
res.json({ type: streakType, count });
});
// Trend data — last reading vs 1h ago for each tile
app.get('/api/station/trend', requireDashboardAuth, (_req, res) => {
const hour = loadStationReadings(3600000);
if (hour.length < 2) return res.json({});
const now = hour[hour.length - 1];
const hourAgo = hour[0];
const diff = (field) => {
const a = hourAgo[field], b = now[field];
if (a == null || b == null) return null;
const d = b - a;
return d > 0.1 ? 'up' : d < -0.1 ? 'down' : 'flat';
};
res.json({
tempf: diff('tempf'), humidity: diff('humidity'), dewPoint: diff('dewPoint'),
windspeedmph: diff('windspeedmph'), baromrelin: diff('baromrelin'),
uv: diff('uv'), solarradiation: diff('solarradiation'),
});
});
// Alert history log
// ── NWS alert details (looked up by alert id, cached on disk) ────────────────
const ALERT_DETAIL_FILE = path.join(DATA_DIR, 'alert-details.json');
let alertDetails = {};
try { alertDetails = JSON.parse(fs.readFileSync(ALERT_DETAIL_FILE, 'utf8')); } catch {}
let alertDetailsDirty = false;
function saveAlertDetails() {
if (!alertDetailsDirty) return;
try { fs.writeFileSync(ALERT_DETAIL_FILE + '.tmp', JSON.stringify(alertDetails)); fs.renameSync(ALERT_DETAIL_FILE + '.tmp', ALERT_DETAIL_FILE); alertDetailsDirty = false; }
catch (e) { console.error('[alerts] detail cache save error:', e.message); }
}
setInterval(saveAlertDetails, 30 * 1000).unref();
const ALERT_ID_RE = /^https:\/\/api\.weather\.gov\/alerts\/[A-Za-z0-9:._-]+$/;
const alertLookupsInFlight = new Map();
async function lookupAlertDetail(id) {
const hit = alertDetails[id];
if (hit && !hit.unavailable) return hit;
if (hit && hit.unavailable && Date.now() - hit.at < 6 * 3600000) return hit; // don't hammer NWS for gone alerts
if (alertLookupsInFlight.has(id)) return alertLookupsInFlight.get(id);
const job = (async () => {
try {
const r = await fetchWithRetry(id, { attempts: 1, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } } });
const f = await r.json();
const p = f.properties || f;
if (!p.event) throw new Error('no event');
alertDetails[id] = { ...nwsLogFields(p), fetchedAt: Date.now() };
} catch (e) {
alertDetails[id] = { unavailable: true, at: Date.now() };
}
alertDetailsDirty = true;
alertLookupsInFlight.delete(id);
return alertDetails[id];
})();
alertLookupsInFlight.set(id, job);
return job;
}
app.get('/api/dashboard/alert-detail', requireDashboardAuth, async (req, res) => {
const id = String(req.query.id || '');
if (!ALERT_ID_RE.test(id)) return res.status(400).json({ error: 'Invalid alert id' });
const d = await lookupAlertDetail(id);
if (d.unavailable) return res.status(404).json({ error: 'NWS no longer provides this alert' });
res.json(d);
});
app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30));
const results = [];
const now = Date.now();
for (let i = 0; i < days; i++) {
const day = new Date(now - i * 86400000).toISOString().slice(0, 10);
const file = path.join(ALERTS_DIR, `${day}.jsonl`);
if (!fs.existsSync(file)) continue;
try {
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
for (const line of lines) {
try { results.push(JSON.parse(line)); } catch {}
}
} catch {}
}
// Fill in text/area for older entries from the detail cache when we have it
for (const e of results) {
if (e.type !== 'nws' || e.description || !e.alertId) continue;
const d = alertDetails[e.alertId];
if (d && !d.unavailable) Object.assign(e, Object.fromEntries(Object.entries(d).filter(([k, v]) => v != null && e[k] == null)));
}
results.sort((a, b) => b.ts - a.ts);
res.json(results);
});
app.listen(PORT, () => {
console.log(`Weather app running on port ${PORT}`);
if (hasUsers()) {
console.log(`Dashboard recovery token (changes daily): ${getDailyRecoveryToken()}`);
console.log(` Use at http://localhost:${PORT}/dashboard/recover`);
}
});