Alerts tab: fix hurricane/tropical storm bugs; rich alert cards matching the dashboard
Publish Container / publish (push) Successful in 2m44s

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>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 14:00:41 -04:00
1 parent 8252017417
commit 72a4a3fe38
8 files changed
+359 -196

No files matched your search

+12 -5
View File
@@ -831,7 +831,7 @@ app.get('/api/alerts', async (req, res) => {
return res.status(400).json({ error: 'Invalid lat/lon' });
}
try {
const r = await fetchWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
const r = await fetchWithRetry(`${NWS_BASE}/alerts/active?point=${lat},${lon}`, {
attempts: 1,
timeoutMs: 12000,
fetchOptions: {
@@ -928,10 +928,11 @@ app.post('/api/push-unsubscribe', (req, res) => {
// ── 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('https://www.nhc.noaa.gov/CurrentStorms.json', {
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' } },
});
@@ -945,6 +946,11 @@ app.get('/api/hurricanes', async (_req, res) => {
}
});
// 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)) {
@@ -954,7 +960,7 @@ app.get('/api/hurricanes/track', async (req, res) => {
const sid = stormId.toLowerCase();
let storm = (hCache.data?.storms || []).find(s => s.id?.toLowerCase() === sid);
if (!storm) {
const r = await fetchWithRetry('https://www.nhc.noaa.gov/CurrentStorms.json', {
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);
@@ -992,7 +998,7 @@ app.get('/api/hurricanes/track', async (req, res) => {
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: storm?.classification || 'TS',
type: typeForKt(curWindM ? parseInt(curWindM[1]) : 0, storm?.classification),
});
}
@@ -1004,7 +1010,8 @@ app.get('/api/hurricanes/track', async (req, res) => {
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/);
positions.push({ tau, lat, lon, windKt: windM ? parseInt(windM[1]) : 0, type: storm?.classification || 'TS' });
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 });