diff --git a/app/public/dashboard-ui.css b/app/public/dashboard-ui.css index c599ff4..1f5943e 100644 --- a/app/public/dashboard-ui.css +++ b/app/public/dashboard-ui.css @@ -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); } diff --git a/app/public/dashboard.html b/app/public/dashboard.html index 275bfb6..80b75d8 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -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 = `${isNws ? 'NWS' : 'Threshold'}`; + 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(`${esc(it.severity)}`); + 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 = `
What this means: ${esc(meaning)}
` : ''} + ${it.headline ? `Headline: ${esc(it.headline)}
` : ''} + ${it.areaDesc ? `Area: ${esc(ahArea(it.areaDesc))}
` : ''} + ${it.description ? `Details:\n${esc(it.description)}
` : loading ? 'Fetching the full text from the National Weather Serviceβ¦
' : ahState.gone.has(it.alertId) ? 'The National Weather Service no longer provides the full text for this older alert.
' : ''} + ${it.instruction ? `What to do:\n${esc(it.instruction)}
` : ''} +${[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(' Β· ')}
+${esc(it.body || '')}