Alert history: say what each alert is for, where, and when
Publish Container / publish (push) Successful in 3m33s
Publish Container / publish (push) Successful in 3m33s
- Plain-language meaning for common NWS products, shown in the row and expanded view - Details column: reason, area, severity, effective window; click for full text/instructions - New alerts log area, severity, urgency, certainty and sender - Older entries are filled in from NWS by alert id (cached in data/alert-details.json); alerts NWS no longer serves fall back to the built-in meaning Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72d813bd61
commit
114386a1b2
3 files changed
+231
-33
No files matched your search
@@ -131,3 +131,17 @@ a { color: var(--ui); }
|
||||
.rec-yr { display: block; font-size: 0.68rem; color: var(--ink-light); font-weight: 600; white-space: nowrap; }
|
||||
|
||||
.rec-cal tr.empty { opacity: .4; }
|
||||
|
||||
/* Alert history: what / where / when */
|
||||
.ah-what { font-size: 0.86rem; line-height: 1.4; }
|
||||
.ah-meta { margin-top: 0.25rem; font-size: 0.74rem; color: var(--ink-light); line-height: 1.5; }
|
||||
.ah-sev { display: inline-block; padding: 0 0.45rem; border-radius: 99px; font-weight: 700; font-size: 0.68rem; border: 1px solid var(--border); }
|
||||
.ah-sev.sev-extreme { color: #ef4444; border-color: #ef4444; } .ah-sev.sev-severe { color: #f97316; border-color: #f97316; }
|
||||
.ah-sev.sev-moderate { color: #eab308; border-color: #eab308; } .ah-sev.sev-minor { color: var(--cool); border-color: var(--cool); }
|
||||
.ah-row { cursor: pointer; }
|
||||
.ah-row.open td { background: color-mix(in srgb, var(--ui) 6%, transparent); }
|
||||
.ah-full { margin-top: 0.6rem; padding-top: 0.6rem; border-top: 1px dashed var(--border); font-size: 0.82rem; line-height: 1.5; cursor: default; }
|
||||
.ah-full p { margin: 0 0 0.5rem; }
|
||||
.ah-desc { white-space: pre-wrap; }
|
||||
.ah-meaning { padding: 0.5rem 0.7rem; border-radius: 10px; background: color-mix(in srgb, var(--ui) 10%, transparent); }
|
||||
.ah-note { font-size: 0.74rem; color: var(--ink-light); }
|
||||
+156
-30
@@ -1603,6 +1603,151 @@ const TAG_LABELS = {
|
||||
|
||||
let alertHistoryDays = 30;
|
||||
|
||||
// Plain-language meaning of common NWS products, so the history says what an alert is *for*
|
||||
const ALERT_GLOSSARY = {
|
||||
'heat advisory': 'Dangerously hot conditions expected. Limit time outdoors, drink plenty of water, and check on children, older adults and pets.',
|
||||
'excessive heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.',
|
||||
'excessive heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.',
|
||||
'extreme heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.',
|
||||
'extreme heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.',
|
||||
'severe thunderstorm watch': 'Storms capable of damaging wind or large hail are possible in the area. Stay alert and be ready to take shelter.',
|
||||
'severe thunderstorm warning': 'A severe storm with damaging wind and/or large hail is happening or imminent. Move indoors away from windows.',
|
||||
'tornado watch': 'Conditions are favorable for tornadoes. Stay alert and know where you would take shelter.',
|
||||
'tornado warning': 'A tornado is occurring or imminent. Take shelter immediately in the lowest, most interior room.',
|
||||
'special weather statement': 'A short notice about a significant but non-warning weather event, often a strong storm, gusty winds, dense fog or sudden changes.',
|
||||
'flood watch': 'Flooding is possible. Be prepared to move to higher ground and avoid low-lying areas.',
|
||||
'flood advisory': 'Minor flooding is occurring or expected. Avoid flooded roads and low spots.',
|
||||
'flood warning': 'Flooding is happening or imminent. Move to higher ground and never drive through flood water.',
|
||||
'flash flood watch': 'Flash flooding is possible. Be ready to move to higher ground quickly.',
|
||||
'flash flood warning': 'Flash flooding is occurring or imminent. Move to higher ground now.',
|
||||
'wind advisory': 'Sustained or gusty winds that can blow around unsecured objects and make driving difficult for high-profile vehicles.',
|
||||
'high wind watch': 'Damaging winds are possible. Secure outdoor objects.',
|
||||
'high wind warning': 'Damaging winds are occurring or imminent. Secure objects and avoid travel if possible.',
|
||||
'freeze watch': 'A freeze is possible. Protect sensitive plants, pets and exposed pipes.',
|
||||
'freeze warning': 'Temperatures at or below freezing are expected. Protect plants, pets and exposed pipes.',
|
||||
'frost advisory': 'Frost is expected and can damage sensitive plants.',
|
||||
'hard freeze warning': 'Severe freezing temperatures are expected. Protect plants, pets and pipes.',
|
||||
'winter storm watch': 'Heavy snow and/or ice is possible. Prepare for hazardous travel.',
|
||||
'winter storm warning': 'Heavy snow and/or ice is occurring or imminent. Travel may be dangerous or impossible.',
|
||||
'winter weather advisory': 'Snow, sleet or freezing rain will cause slippery roads and travel difficulty.',
|
||||
'ice storm warning': 'Significant ice accumulation is expected. Power outages and dangerous travel are likely.',
|
||||
'blizzard warning': 'Heavy snow with strong winds and very low visibility. Avoid travel.',
|
||||
'wind chill advisory': 'Dangerously cold wind chills are expected. Dress in layers and limit time outside.',
|
||||
'wind chill warning': 'Life-threatening wind chills are expected. Frostbite can occur quickly.',
|
||||
'dense fog advisory': 'Fog is reducing visibility to a quarter mile or less. Slow down and use low-beam headlights.',
|
||||
'dense smoke advisory': 'Smoke is reducing visibility and air quality.',
|
||||
'air quality alert': 'Air quality is unhealthy for some or all people. Limit prolonged outdoor exertion.',
|
||||
'red flag warning': 'Dry, windy and warm conditions create a high wildfire risk. Avoid open flames and outdoor burning.',
|
||||
'fire weather watch': 'Conditions may soon create a high wildfire risk.',
|
||||
'rip current statement': 'Dangerous rip currents at the beach. Stay out of the water or swim near a lifeguard.',
|
||||
'coastal flood advisory': 'Minor coastal flooding is expected.',
|
||||
'coastal flood warning': 'Significant coastal flooding is occurring or imminent.',
|
||||
'small craft advisory': 'Winds or seas hazardous to small boats.',
|
||||
'tropical storm watch': 'Tropical storm conditions are possible within about 48 hours.',
|
||||
'tropical storm warning': 'Tropical storm conditions are expected within about 36 hours.',
|
||||
'hurricane watch': 'Hurricane conditions are possible within about 48 hours.',
|
||||
'hurricane warning': 'Hurricane conditions are expected within about 36 hours.',
|
||||
'hazardous weather outlook': 'An outlook of possible hazardous weather in the next several days.',
|
||||
'severe weather statement': 'An update on an ongoing severe storm warning.',
|
||||
'flood statement': 'An update on an ongoing flood warning.',
|
||||
};
|
||||
function alertMeaning(event) {
|
||||
const e = String(event || '').toLowerCase().trim();
|
||||
if (ALERT_GLOSSARY[e]) return ALERT_GLOSSARY[e];
|
||||
if (e.endsWith('watch')) return 'A Watch: conditions are favorable for this hazard. Stay alert and be ready to act.';
|
||||
if (e.endsWith('warning')) return 'A Warning: this hazard is happening or imminent. Take action now.';
|
||||
if (e.endsWith('advisory')) return 'An Advisory: conditions may cause inconvenience or danger if you are not careful.';
|
||||
if (e.endsWith('statement')) return 'An informational statement about developing or ongoing weather.';
|
||||
return '';
|
||||
}
|
||||
|
||||
const ahState = { items: [], open: new Set(), loading: new Set(), gone: new Set(), queue: [], running: false };
|
||||
const ahWhen = (iso) => iso ? new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
|
||||
const ahFirstSentence = (t) => {
|
||||
// NWS text is often structured "* WHAT...", "* WHERE...", "* WHEN..."; lead with the WHAT part
|
||||
const what = String(t || '').match(/\*\s*WHAT\.\.\.([\s\S]*?)(?:\n\s*\n|\*\s*[A-Z ]+\.\.\.|$)/);
|
||||
const c = (what ? what[1] : String(t || '')).replace(/\s+/g, ' ').trim(); const m = c.match(/^.*?[.!?](\s|$)/); const r = (m ? m[0] : c).trim(); return r.length > 220 ? r.slice(0, 217) + '…' : r;
|
||||
};
|
||||
const ahArea = (a) => String(a || '').replace(/;\s*/g, ', ');
|
||||
const SEV_ORDER = { Extreme: 'sev-extreme', Severe: 'sev-severe', Moderate: 'sev-moderate', Minor: 'sev-minor' };
|
||||
|
||||
function ahRow(it, i) {
|
||||
const isNws = it.type === 'nws';
|
||||
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
|
||||
const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—');
|
||||
const open = ahState.open.has(i);
|
||||
let what, meta = [];
|
||||
if (isNws) {
|
||||
what = it.description ? ahFirstSentence(it.description) : alertMeaning(it.event) || it.headline || '';
|
||||
if (it.areaDesc) meta.push(`📍 ${esc(ahArea(it.areaDesc))}`);
|
||||
else if (ahState.loading.has(it.alertId)) meta.push('📍 looking up area…');
|
||||
if (it.severity && it.severity !== 'Unknown') meta.push(`<span class="ah-sev ${SEV_ORDER[it.severity] || ''}">${esc(it.severity)}</span>`);
|
||||
if (it.effective || it.expires) meta.push(`🕑 ${esc(ahWhen(it.effective))}${it.expires ? ' → ' + esc(ahWhen(it.expires)) : ''}`);
|
||||
} else {
|
||||
what = it.body || '';
|
||||
}
|
||||
let full = '';
|
||||
if (open) {
|
||||
if (isNws) {
|
||||
const meaning = alertMeaning(it.event);
|
||||
const loading = ahState.loading.has(it.alertId);
|
||||
full = `<div class="ah-full">
|
||||
${meaning ? `<p class="ah-meaning"><b>What this means:</b> ${esc(meaning)}</p>` : ''}
|
||||
${it.headline ? `<p><b>Headline:</b> ${esc(it.headline)}</p>` : ''}
|
||||
${it.areaDesc ? `<p><b>Area:</b> ${esc(ahArea(it.areaDesc))}</p>` : ''}
|
||||
${it.description ? `<p class="ah-desc"><b>Details:</b>\n${esc(it.description)}</p>` : loading ? '<p class="ah-note">Fetching the full text from the National Weather Service…</p>' : ahState.gone.has(it.alertId) ? '<p class="ah-note">The National Weather Service no longer provides the full text for this older alert.</p>' : ''}
|
||||
${it.instruction ? `<p class="ah-desc"><b>What to do:</b>\n${esc(it.instruction)}</p>` : ''}
|
||||
<p class="ah-note">${[it.severity && 'Severity: ' + it.severity, it.urgency && 'Urgency: ' + it.urgency, it.certainty && 'Certainty: ' + it.certainty, it.senderName && 'Issued by ' + it.senderName].filter(Boolean).map(esc).join(' · ')}</p>
|
||||
</div>`;
|
||||
} else full = `<div class="ah-full"><p>${esc(it.body || '')}</p></div>`;
|
||||
}
|
||||
return `<tr class="ah-row${open ? ' open' : ''}" data-i="${i}" title="Click for the full alert">
|
||||
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
|
||||
<td>${badge}</td>
|
||||
<td style="font-weight:600">${esc(label)}</td>
|
||||
<td><div class="ah-what">${esc(what)}</div>${meta.length ? `<div class="ah-meta">${meta.join(' · ')}</div>` : ''}${full}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function ahRender() {
|
||||
const tbody = document.getElementById('ahBody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = ahState.items.map(ahRow).join('');
|
||||
tbody.querySelectorAll('.ah-full').forEach((f) => f.addEventListener('click', (e) => e.stopPropagation())); // allow selecting/copying text
|
||||
tbody.querySelectorAll('.ah-row').forEach((row) => row.addEventListener('click', () => {
|
||||
const i = +row.dataset.i, it = ahState.items[i];
|
||||
if (ahState.open.has(i)) ahState.open.delete(i); else { ahState.open.add(i); ahLookup(it, true); }
|
||||
ahRender();
|
||||
}));
|
||||
}
|
||||
|
||||
// Fetch missing text/area for older NWS entries (cached on the server), a few at a time
|
||||
function ahApply(id, detail) {
|
||||
for (const it of ahState.items) if (it.alertId === id) for (const [k, v] of Object.entries(detail || {})) if (v != null && it[k] == null) it[k] = v;
|
||||
}
|
||||
function ahLookup(it, urgent) {
|
||||
if (!it || it.type !== 'nws' || !it.alertId || (it.description && it.areaDesc) || ahState.gone.has(it.alertId) || ahState.loading.has(it.alertId)) return;
|
||||
if (urgent) ahState.queue.unshift(it.alertId); else ahState.queue.push(it.alertId);
|
||||
ahState.loading.add(it.alertId);
|
||||
ahPump();
|
||||
}
|
||||
async function ahPump() {
|
||||
if (ahState.running) return;
|
||||
ahState.running = true;
|
||||
while (ahState.queue.length) {
|
||||
const id = ahState.queue.shift();
|
||||
try {
|
||||
const r = await fetch('/api/dashboard/alert-detail?id=' + encodeURIComponent(id));
|
||||
if (r.ok) ahApply(id, await r.json()); else ahState.gone.add(id);
|
||||
} catch { ahState.gone.add(id); }
|
||||
ahState.loading.delete(id);
|
||||
ahRender();
|
||||
await new Promise((res) => setTimeout(res, 120));
|
||||
}
|
||||
ahState.running = false;
|
||||
}
|
||||
|
||||
async function loadAlertHistory(days) {
|
||||
alertHistoryDays = days;
|
||||
const body = document.getElementById('alertHistoryBody');
|
||||
@@ -1611,7 +1756,7 @@ async function loadAlertHistory(days) {
|
||||
const r = await fetch(`/api/dashboard/alert-history?days=${days}`);
|
||||
if (!r.ok) throw new Error();
|
||||
const items = await r.json();
|
||||
const dayBtns = body.querySelectorAll?.('.range-btn') || [];
|
||||
ahState.items = items; ahState.open = new Set(); ahState.queue = []; ahState.loading = new Set(); ahState.gone = new Set();
|
||||
body.innerHTML = `
|
||||
<div class="alert-history-bar">
|
||||
<span style="font-size:0.82rem;color:var(--ink-light)">Range:</span>
|
||||
@@ -1621,35 +1766,16 @@ async function loadAlertHistory(days) {
|
||||
${items.length === 0
|
||||
? `<div class="ah-empty">No alerts logged in the last ${days} days.</div>`
|
||||
: `<div class="alert-history-table-wrap"><table class="alert-history-table">
|
||||
<thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Details</th></tr></thead>
|
||||
<tbody>${items.map(it => {
|
||||
const isNws = it.type === 'nws';
|
||||
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
|
||||
const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—');
|
||||
const detail = isNws ? (it.headline || '') : (it.body || '');
|
||||
const full = isNws ? [it.headline, it.description, it.instruction && ('What to do: ' + it.instruction)].filter(Boolean).join('\n\n') : detail;
|
||||
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
return `<tr class="ah-row" data-full="${esc(full)}" title="Click to expand">
|
||||
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
|
||||
<td>${badge}</td>
|
||||
<td style="font-weight:600">${label}</td>
|
||||
<td><div class="ah-body">${esc(detail)}</div></td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table></div>`
|
||||
}`;
|
||||
body.querySelectorAll('.ah-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const el = row.querySelector('.ah-body');
|
||||
const open = row.classList.toggle('open');
|
||||
el.textContent = open ? row.dataset.full : el.dataset.short;
|
||||
});
|
||||
const el = row.querySelector('.ah-body');
|
||||
el.dataset.short = el.textContent;
|
||||
});
|
||||
body.querySelectorAll('[data-ahdays]').forEach(btn => {
|
||||
btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays)));
|
||||
});
|
||||
<thead><tr><th>Time</th><th>Type</th><th>Alert</th><th>What it's for · where · when</th></tr></thead>
|
||||
<tbody id="ahBody"></tbody>
|
||||
</table></div>`}`;
|
||||
body.querySelectorAll('[data-ahdays]').forEach(btn => btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays))));
|
||||
if (items.length) {
|
||||
ahRender();
|
||||
// Look up missing details in the background, newest first
|
||||
const seen = new Set();
|
||||
for (const it of items) if (it.type === 'nws' && it.alertId && !(it.description && it.areaDesc) && !seen.has(it.alertId)) { seen.add(it.alertId); ahLookup(it, false); }
|
||||
}
|
||||
} catch {
|
||||
document.getElementById('alertHistoryBody').innerHTML = '<div class="status error">Failed to load alert history.</div>';
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user