Merge branch 'fix/network-main-parity' into main (resolve conflicts)

This commit is contained in:
Nate Emmert committed 2026-03-17 14:21:49 -04:00
commit 49b54e0fe4
22 files changed
+11297 -2079

No files matched your search

+1246 -694
View File
File diff suppressed because it is too large. Load diff
+972 -154
View File
File diff suppressed because it is too large. Load diff
+1908 -849
View File
File diff suppressed because it is too large. Load diff
+15 -48
View File
@@ -1,12 +1,16 @@
'use strict';
const CACHE = 'weather-v4';
self.addEventListener('message', event => {
if (event.data === 'skipWaiting') self.skipWaiting();
});
// App shell — cached on install for offline use
const CACHE = 'weather-v5';
// App shell cached on install for offline resilience.
const PRECACHE = [
'/',
'/app.js?v=4',
'/style.css?v=4',
'/app.js',
'/style.css',
'/manifest.json',
'/icons/icon.svg'
];
@@ -22,9 +26,7 @@ self.addEventListener('install', event => {
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(k => k !== CACHE).map(k => caches.delete(k))
))
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
.then(() => self.clients.claim())
);
});
@@ -33,7 +35,7 @@ self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET, API calls, and map/radar tile requests
// Never intercept API/tile traffic.
if (
request.method !== 'GET' ||
url.pathname.startsWith('/api/') ||
@@ -45,14 +47,14 @@ self.addEventListener('fetch', event => {
return;
}
const isAppShellRequest =
const isShell =
request.mode === 'navigate' ||
request.destination === 'document' ||
request.destination === 'script' ||
request.destination === 'style';
// Prefer fresh app shell assets so UI updates are visible immediately.
if (isAppShellRequest) {
if (isShell) {
// Network-first keeps app updates fresh; cache as fallback.
event.respondWith(
fetch(request)
.then(response => {
@@ -67,11 +69,11 @@ self.addEventListener('fetch', event => {
return;
}
// Cache-first for static assets.
event.respondWith(
caches.match(request).then(cached => {
if (cached) return cached;
return fetch(request).then(response => {
// Only cache successful same-origin and trusted CDN responses
if (response.ok && (
url.origin === self.location.origin ||
url.hostname.includes('googleapis') ||
@@ -85,39 +87,4 @@ self.addEventListener('fetch', event => {
});
})
);
});
self.addEventListener('push', event => {
let payload = { title: 'Weather alert', body: 'New weather update available.', url: '/' };
try {
const data = event.data ? event.data.json() : null;
if (data) payload = { ...payload, ...data };
} catch {
// Ignore malformed payload and use defaults.
}
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
icon: '/icons/icon.svg',
badge: '/icons/icon.svg',
data: { url: payload.url || '/' },
})
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const target = event.notification?.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => {
const existing = windowClients.find(c => c.url.includes(self.location.origin));
if (existing) {
existing.focus();
existing.navigate(target);
return;
}
return clients.openWindow(target);
})
);
});
});
+185 -333
View File
@@ -1,130 +1,30 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const webpush = require('web-push');
const app = express();
const PORT = process.env.PORT || 3000;
const weatherCache = new Map();
app.use(express.json({ limit: '256kb' }));
// Lightweight request logging for observability.
app.use((req, res, next) => {
const started = Date.now();
res.on('finish', () => {
const ms = Date.now() - started;
console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
});
next();
});
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchJsonWithRetry(url, options = {}, attempts = 3, initialDelayMs = 300) {
let lastErr = null;
for (let i = 0; i < attempts; i++) {
try {
const r = await fetch(url, options);
if (!r.ok) {
const text = await r.text().catch(() => '');
throw new Error(`Upstream status ${r.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
}
return await r.json();
} catch (e) {
lastErr = e;
if (i < attempts - 1) {
await sleep(initialDelayMs * Math.pow(2, i));
}
}
}
throw lastErr || new Error('Unknown upstream fetch error');
}
// --------------- Persistent data storage ---------------
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const SUBS_FILE = path.join(DATA_DIR, 'subscriptions.json');
const SENT_IDS_FILE = path.join(DATA_DIR, 'sent-alert-ids.json');
try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (_) {}
function loadSubscriptions() {
try {
const arr = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8'));
const map = new Map();
for (const entry of arr) {
if (entry?.subscription?.endpoint) map.set(entry.subscription.endpoint, entry);
}
return map;
} catch (_) { return new Map(); }
}
function saveSubscriptions() {
try {
fs.writeFileSync(SUBS_FILE, JSON.stringify(Array.from(subscriptions.values()), null, 2));
} catch (e) { console.error('Failed to save subscriptions:', e.message); }
}
const subscriptions = loadSubscriptions();
if (subscriptions.size) console.log(`Loaded ${subscriptions.size} push subscription(s) from disk.`);
function loadSentAlertIds() {
try {
return new Map(JSON.parse(fs.readFileSync(SENT_IDS_FILE, 'utf8')));
} catch (_) { return new Map(); }
}
function saveSentAlertIds() {
try {
fs.writeFileSync(SENT_IDS_FILE, JSON.stringify(Array.from(sentAlertIds.entries()), null, 2));
} catch (e) { console.error('Failed to save sent alert IDs:', e.message); }
}
const sentAlertIds = loadSentAlertIds();
// --------------------------------------------------------
function getVapidConfig() {
const publicKey = process.env.VAPID_PUBLIC_KEY || '';
const privateKey = process.env.VAPID_PRIVATE_KEY || '';
const subject = process.env.VAPID_SUBJECT || 'mailto:nate@necloud.us';
return { publicKey, privateKey, subject };
}
function pushConfigured() {
const cfg = getVapidConfig();
return Boolean(cfg.publicKey && cfg.privateKey);
}
function ensurePushConfigured() {
const cfg = getVapidConfig();
if (!cfg.publicKey || !cfg.privateKey) {
return false;
}
webpush.setVapidDetails(cfg.subject, cfg.publicKey, cfg.privateKey);
return true;
}
// HTML: never cache (always revalidate so SW/app updates are picked up immediately)
// 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') {
// Short-lived cache — browser revalidates every 60 s but still gets a 304 fast
} 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')));
app.get('/healthz', (_req, res) => {
res.json({
ok: true,
uptimeSec: Math.round(process.uptime()),
pushConfigured: pushConfigured(),
subscriptionCount: subscriptions.size,
now: new Date().toISOString(),
});
// Prevent browser favicon requests from showing noisy 404s.
app.get('/favicon.ico', (_req, res) => {
res.status(204).end();
});
// Geocoding proxy
@@ -133,8 +33,58 @@ app.get('/api/geocode', async (req, res) => {
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`;
const data = await fetchJsonWithRetry(url);
res.json(data);
let openMeteoData = null;
for (let attempt = 1; attempt <= 2; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 9000);
try {
const r = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!r.ok) throw new Error(`Open-Meteo geocode status ${r.status}`);
openMeteoData = await r.json();
break;
} catch (err) {
clearTimeout(timeoutId);
if (attempt === 2) {
console.warn('Open-Meteo geocode failed after retries:', err.message);
} else {
await new Promise(resolve => setTimeout(resolve, 300 * attempt));
}
}
}
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 fetch(`https://nominatim.openstreetmap.org/search?${nomParams}`, {
headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/json',
},
});
if (!nom.ok) {
throw new Error(`Nominatim geocode status ${nom.status}`);
}
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) {
res.status(500).json({ error: 'Geocoding failed', detail: e.message });
}
@@ -146,31 +96,6 @@ app.get('/api/reverse-geocode', async (req, res) => {
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
try {
// Prefer Open-Meteo reverse geocoding for stable city/region labels.
const omParams = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
count: '1',
language: 'en',
format: 'json',
});
const omData = await fetchJsonWithRetry(`https://geocoding-api.open-meteo.com/v1/reverse?${omParams}`, {}, 2, 200).catch(() => null);
if (omData) {
const top = omData?.results?.[0];
if (top) {
return res.json({
name: top.name || top.admin2 || top.admin1 || top.country || 'Detected location',
admin1: top.admin1 || top.admin2 || '',
country: top.country || '',
latitude: Number(lat),
longitude: Number(lon),
timezone: top.timezone || 'auto',
display_name: [top.name, top.admin1, top.country].filter(Boolean).join(', '),
});
}
}
const params = new URLSearchParams({
format: 'jsonv2',
lat: String(lat),
@@ -179,13 +104,18 @@ app.get('/api/reverse-geocode', async (req, res) => {
addressdetails: '1',
});
const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, {
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/json',
},
});
if (!r.ok) {
throw new Error(`Reverse geocoding failed with status ${r.status}`);
}
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';
@@ -209,13 +139,14 @@ app.get('/api/weather', async (req, res) => {
if (!lat || !lon) return res.status(400).json({ error: 'Missing 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','surface_pressure','visibility'
'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility'
].join(','),
hourly: [
'temperature_2m','precipitation_probability','weather_code',
@@ -231,9 +162,44 @@ app.get('/api/weather', async (req, res) => {
temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
precipitation_unit: useMetric ? 'mm' : 'inch'
});
const data = await fetchJsonWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`);
res.json(data);
let lastError = null;
const upstreamTimeoutMs = 12000;
for (let attempt = 1; attempt <= 3; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs);
try {
const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: controller.signal });
clearTimeout(timeoutId);
const body = await r.text();
if (!r.ok) {
throw new Error(`Open-Meteo status ${r.status}: ${body.slice(0, 200)}`);
}
const data = JSON.parse(body);
weatherCache.set(cacheKey, { data, at: Date.now() });
return res.json(data);
} catch (err) {
clearTimeout(timeoutId);
lastError = err;
const reason = err?.name === 'AbortError' ? `timeout after ${upstreamTimeoutMs}ms` : (err?.message || 'unknown error');
console.warn(`Weather upstream attempt ${attempt}/3 failed: ${reason}`);
if (attempt < 3) {
await new Promise(resolve => setTimeout(resolve, 500 * attempt));
}
}
}
throw lastError || new Error('Weather upstream failed');
} 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(500).json({ error: 'Weather fetch failed', detail: e.message });
}
});
@@ -250,10 +216,34 @@ app.get('/api/air-quality', async (req, res) => {
current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
forecast_days: 1,
});
const data = await fetchJsonWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`);
res.json(data);
let lastError = null;
for (let attempt = 1; attempt <= 2; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 12000);
try {
const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!r.ok) {
throw new Error(`Open-Meteo status ${r.status}`);
}
const data = await r.json();
return res.json(data);
} catch (err) {
clearTimeout(timeoutId);
lastError = err;
if (attempt < 2) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
}
throw lastError || new Error('Air quality upstream failed');
} catch (e) {
res.status(500).json({ error: 'Air quality fetch failed', detail: e.message });
console.error('Air quality fetch error:', e);
res.setHeader('X-Air-Quality-Fallback', 'empty');
res.json({ current: {} });
}
});
@@ -262,211 +252,73 @@ app.get('/api/alerts', async (req, res) => {
const { lat, lon } = req.query;
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
try {
const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 12000);
const r = await fetch(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/geo+json',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!r.ok) {
throw new Error(`NWS API returned ${r.status}`);
}
const data = await r.json();
res.json(data);
} catch (e) {
res.status(500).json({ error: 'Alerts fetch failed', detail: e.message });
console.error('Alerts fetch error:', e.message);
res.setHeader('X-Alerts-Fallback', 'empty');
res.json({ features: [] });
}
});
// Lightning activity from active weather.gov thunderstorm alerts (real alert geometries).
app.get('/api/lightning', async (req, res) => {
const { lat, lon, age } = req.query;
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
const ageMinutes = Number(age || 15);
// Radar/satellite tile configuration
app.get('/api/radar/times', async (req, res) => {
try {
const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/geo+json',
},
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,
};
});
const cutoff = Date.now() - (Number.isFinite(ageMinutes) ? ageMinutes : 15) * 60 * 1000;
const features = (data?.features || []).filter(f => {
const p = f?.properties || {};
const event = String(p.event || '').toLowerCase();
const relevant = event.includes('thunderstorm') || event.includes('lightning');
if (!relevant) return false;
const ts = Date.parse(p.sent || p.onset || p.effective || '');
if (Number.isNaN(ts)) return true;
return ts >= cutoff;
// 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({
source: 'weather.gov alerts',
count: features.length,
features,
ageMinutes: Number.isFinite(ageMinutes) ? ageMinutes : 15,
provider: 'iem-radar-rainviewer-sat',
radar: {
past: radarPast,
nowcast: [],
},
satellite: {
infrared: satelliteInfrared,
},
});
} catch (e) {
res.status(500).json({ error: 'Lightning fetch failed', detail: e.message });
}
});
app.get('/api/push/public-key', (_req, res) => {
const { publicKey } = getVapidConfig();
res.json({
enabled: pushConfigured(),
publicKey: publicKey || null,
});
});
app.post('/api/push/subscribe', (req, res) => {
const { subscription, location } = req.body || {};
if (!subscription?.endpoint) {
return res.status(400).json({ error: 'Missing subscription endpoint' });
}
subscriptions.set(subscription.endpoint, {
subscription,
location: location || null,
createdAt: Date.now(),
});
saveSubscriptions();
res.json({ ok: true, count: subscriptions.size });
});
app.post('/api/push/unsubscribe', (req, res) => {
const { endpoint } = req.body || {};
if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });
subscriptions.delete(endpoint);
saveSubscriptions();
res.json({ ok: true, count: subscriptions.size });
});
app.post('/api/push/test', async (req, res) => {
if (!ensurePushConfigured()) {
return res.status(400).json({
error: 'Push is not configured. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY.',
});
}
const payload = JSON.stringify({
title: req.body?.title || 'Weather test notification',
body: req.body?.body || 'Push notifications are working.',
url: req.body?.url || '/',
ts: Date.now(),
});
let sent = 0;
let removed = 0;
const errors = [];
const entries = Array.from(subscriptions.values());
for (const entry of entries) {
try {
await webpush.sendNotification(entry.subscription, payload);
sent++;
} catch (e) {
const statusCode = e?.statusCode;
if (statusCode === 404 || statusCode === 410) {
subscriptions.delete(entry.subscription.endpoint);
removed++;
saveSubscriptions();
} else {
errors.push(String(e.message || e));
}
}
}
res.json({ ok: true, sent, removed, errors, count: subscriptions.size });
});
// --------------- Background NWS alert poller ---------------
// Runs every ALERT_POLL_INTERVAL_MS (default 5 min).
// Sends push notifications to all subscribers for any new active NWS alerts
// at their saved location. Deduplicates by alert ID (persisted to disk).
function cleanSentAlertIds() {
const cutoff = Date.now() - 48 * 60 * 60 * 1000;
for (const [id, ts] of sentAlertIds) {
if (ts < cutoff) sentAlertIds.delete(id);
}
}
async function alertPoller() {
if (!pushConfigured() || !ensurePushConfigured()) return;
if (subscriptions.size === 0) return;
cleanSentAlertIds();
// Group subscribers by rounded lat/lon so we don't spam the NWS API.
const byLocation = new Map();
for (const entry of subscriptions.values()) {
const loc = entry.location;
if (!loc?.latitude || !loc?.longitude) continue;
const key = `${Number(loc.latitude).toFixed(2)},${Number(loc.longitude).toFixed(2)}`;
if (!byLocation.has(key)) byLocation.set(key, { loc, entries: [] });
byLocation.get(key).entries.push(entry);
}
let savedIds = false;
for (const { loc, entries } of byLocation.values()) {
try {
const data = await fetchJsonWithRetry(
`https://api.weather.gov/alerts/active?point=${loc.latitude},${loc.longitude}`,
{ headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } }
).catch(() => null);
if (!data?.features?.length) continue;
for (const feature of data.features) {
const id = feature?.properties?.id;
if (!id || sentAlertIds.has(id)) continue;
const props = feature.properties || {};
const title = props.event || 'Weather Alert';
const area = props.areaDesc ? props.areaDesc.split(';')[0].trim() : (loc.name || 'Your area');
const headline = props.headline || props.description?.slice(0, 120) || title;
const payload = JSON.stringify({
title,
body: `${area}: ${headline}`,
url: '/',
ts: Date.now(),
});
let anySuccess = false;
for (const entry of entries) {
try {
await webpush.sendNotification(entry.subscription, payload);
anySuccess = true;
} catch (e) {
if (e?.statusCode === 404 || e?.statusCode === 410) {
subscriptions.delete(entry.subscription.endpoint);
saveSubscriptions();
}
}
}
if (anySuccess) {
console.log(`Alert push sent: [${id}] ${title} → ${area}`);
sentAlertIds.set(id, Date.now());
savedIds = true;
}
}
} catch (e) {
console.error('Alert poller error:', e.message);
}
}
if (savedIds) saveSentAlertIds();
}
const POLL_INTERVAL_MS = Number(process.env.ALERT_POLL_INTERVAL_MS) || 5 * 60 * 1000;
setInterval(alertPoller, POLL_INTERVAL_MS);
// -----------------------------------------------------------
// RainViewer timestamps proxy
app.get('/api/radar/times', async (req, res) => {
try {
const data = await fetchJsonWithRetry('https://api.rainviewer.com/public/weather-maps.json');
res.json(data);
} catch (e) {
console.error('Radar config error:', e.message);
res.status(500).json({ error: 'Radar times failed', detail: e.message });
}
});