fix(dashboard): weather feed shows location + hazard, prioritizes local

- Event feed shows event_type + area_desc instead of timestamp headline
- First sentence of description shown as hazard summary
- Local events (matching NWS zones) pinned to top with highlight
- Nearby events grouped below, slightly dimmed
- Dedup by event_id
This commit is contained in:
zvx-echo6 2026-05-13 20:33:48 -06:00
commit 21d6520ffd
2 changed files with 267 additions and 197 deletions

View file

@ -465,7 +465,7 @@ const SEVERITY_COLORS: Record<string, string> = {
emergency: 'bg-red-700/20 text-red-200 border-red-700/30', emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
} }
function EventFeedItem({ event }: { event: EnvEvent }) { function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean }) {
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source } const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
const Icon = sourceConfig.icon const Icon = sourceConfig.icon
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
@ -483,18 +483,43 @@ function EventFeedItem({ event }: { event: EnvEvent }) {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
} }
// Build display title: prefer event_type + area_desc, fall back to headline
const eventType = (event as Record<string, unknown>).event_type as string | undefined
const areaDesc = (event as Record<string, unknown>).area_desc as string | undefined
const description = (event as Record<string, unknown>).description as string | undefined
let title = event.headline
if (eventType && areaDesc) {
// Shorten area description (remove "County" repetition)
const shortArea = areaDesc.replace(/ County/g, '').split(';')[0]
title = `${eventType}${shortArea}`
} else if (eventType) {
title = eventType
}
// Get first sentence of description as subtitle
const subtitle = description ? description.split('. ')[0] : null
return ( return (
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0"> <div className={`flex items-start gap-2 py-2 border-b border-border/50 last:border-0 ${isLocal ? 'border-l-2 border-l-blue-500 pl-2 -ml-2' : ''}`}>
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} /> <Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}> <span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
{event.severity || 'info'} {event.severity || 'info'}
</span> </span>
{isLocal && (
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
LOCAL
</span>
)}
<span className="text-xs text-slate-500">{sourceConfig.label}</span> <span className="text-xs text-slate-500">{sourceConfig.label}</span>
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span> <span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
</div> </div>
<div className="text-sm text-slate-200 truncate">{event.headline}</div> <div className={`text-sm truncate ${isLocal ? 'text-slate-100' : 'text-slate-300'}`}>{title}</div>
{subtitle && (
<div className="text-xs text-slate-500 truncate mt-0.5">{subtitle}</div>
)}
</div> </div>
</div> </div>
) )
@ -502,8 +527,31 @@ function EventFeedItem({ event }: { event: EnvEvent }) {
// Live Event Feed Card // Live Event Feed Card
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) { function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
// Severity order for sorting
const severityOrder: Record<string, number> = { immediate: 0, priority: 1, routine: 2 }
const sortedEvents = useMemo(() => { const sortedEvents = useMemo(() => {
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0)) // Dedup by event_id
const seen = new Set<string>()
const deduped = events.filter(e => {
if (!e.event_id) return true
if (seen.has(e.event_id)) return false
seen.add(e.event_id)
return true
})
// Sort: local first, then by severity, then by time
return deduped.sort((a, b) => {
const aLocal = (a as Record<string, unknown>).is_local ? 1 : 0
const bLocal = (b as Record<string, unknown>).is_local ? 1 : 0
if (aLocal !== bLocal) return bLocal - aLocal // local first
const aSev = severityOrder[a.severity?.toLowerCase() || 'routine'] ?? 2
const bSev = severityOrder[b.severity?.toLowerCase() || 'routine'] ?? 2
if (aSev !== bSev) return aSev - bSev // higher severity first
return (b.fetched_at || 0) - (a.fetched_at || 0) // newest first
})
}, [events]) }, [events])
// Calculate feed health summary // Calculate feed health summary
@ -528,7 +576,11 @@ function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: E
{sortedEvents.length > 0 ? ( {sortedEvents.length > 0 ? (
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1"> <div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
{sortedEvents.map((event, i) => ( {sortedEvents.map((event, i) => (
<EventFeedItem key={event.event_id || i} event={event} /> <EventFeedItem
key={event.event_id || i}
event={event}
isLocal={(event as Record<string, unknown>).is_local as boolean | undefined}
/>
))} ))}
</div> </div>
) : ( ) : (

View file

@ -21,13 +21,31 @@ async def get_env_status(request: Request):
@router.get("/env/active") @router.get("/env/active")
async def get_active_env(request: Request): async def get_active_env(request: Request):
"""Get active environmental events.""" """Get active environmental events with local zone marking."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return [] return []
return env_store.get_active() events = env_store.get_active()
mesh_zones = set(getattr(env_store, '_mesh_zones', []))
# Dedup by event_id and add is_local field
seen_ids = set()
result = []
for event in events:
event_id = event.get("event_id")
if event_id and event_id in seen_ids:
continue
if event_id:
seen_ids.add(event_id)
# Mark as local if event zones overlap with configured mesh zones
event_zones = set(event.get("areas", []))
event["is_local"] = bool(event_zones & mesh_zones)
result.append(event)
return result
@router.get("/env/swpc") @router.get("/env/swpc")