mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
839bf322d9
commit
21d6520ffd
2 changed files with 267 additions and 197 deletions
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue