feat(storm-intel): Storm Intelligence Hub — Phase 1

Adds a new Storm Intel section to the sidebar and routes for ADMIN and OWNER roles.

- StormIntelPage: full-height layout with Leaflet map + storm polygon overlays,
  severity-coded color system, horizontal filter bar (date range / severity / sort),
  and a scrollable storm event list
- Storm detail drawer slides in from right with hail size, est. homes, days ago,
  conversion window indicator (Hot Zone / Warm / Cold / Too Fresh), intel text,
  and quick-action buttons (Create Lead, Open in Dispatch, Assign Canvassers stub)
- Clicking a storm card or map polygon zooms the map to that polygon and opens the drawer
- useStormEvents hook with USE_MOCK flag, date-range cutoff filter, type/severity filter,
  sort by date or severity, and conversionWindow() helper
- api/storm-events.js: NWS api.weather.gov proxy (free, no auth needed), parses
  GeoJSON polygons and hail size from alert descriptions
- MOCK_STORM_EVENTS: 7 realistic Plano TX storm events with accurate polygon coords
- Sidebar: CloudLightning "Storm Intel" entry added for OWNER and ADMIN nav
This commit is contained in:
Satyam-Rastogi
2026-05-18 16:49:37 +05:30
parent 4c1c454681
commit 2e797c89f6
6 changed files with 979 additions and 1 deletions
+85
View File
@@ -0,0 +1,85 @@
/**
* Storm events hook.
*
* USE_MOCK = true → returns MOCK_STORM_EVENTS from mockStore (local dev)
* USE_MOCK = false → calls /api/storm-events which proxies NWS/NOAA
*
* Flip to false once the Vercel deployment is live (no extra env vars needed —
* NWS api.weather.gov is a free, unauthenticated public endpoint).
*/
import { useState, useEffect, useMemo } from 'react';
import { MOCK_STORM_EVENTS } from '../data/mockStore';
const USE_MOCK = true;
const DATE_RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365 };
const SEVERITY_ORDER = { severe: 4, significant: 3, moderate: 2, trace: 1 };
export function useStormEvents({ dateRange = '6m', typeFilter = 'all', severityFilter = 'all', sortBy = 'date' } = {}) {
const [allEvents, setAllEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const load = async () => {
try {
let data;
if (USE_MOCK) {
await new Promise(r => setTimeout(r, 450));
data = MOCK_STORM_EVENTS;
} else {
const days = DATE_RANGE_DAYS[dateRange] || 180;
const res = await fetch(`/api/storm-events?days=${days}&county=Collin`);
if (!res.ok) throw new Error(`Storm events error: ${res.status}`);
data = await res.json();
}
if (!cancelled) setAllEvents(Array.isArray(data) ? data : []);
} catch (e) {
if (!cancelled) setError(e.message);
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [dateRange]);
const events = useMemo(() => {
const cutoffMs = Date.now() - (DATE_RANGE_DAYS[dateRange] || 180) * 86400000;
let filtered = allEvents.filter(e => {
if (new Date(e.date).getTime() < cutoffMs) return false;
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
if (severityFilter !== 'all' && e.severity !== severityFilter) return false;
return true;
});
if (sortBy === 'severity') {
filtered = filtered.sort((a, b) => (SEVERITY_ORDER[b.severity] ?? 0) - (SEVERITY_ORDER[a.severity] ?? 0));
} else {
filtered = filtered.sort((a, b) => new Date(b.date) - new Date(a.date));
}
return filtered;
}, [allEvents, dateRange, typeFilter, severityFilter, sortBy]);
const totalHomes = useMemo(() => events.reduce((s, e) => s + (e.estimatedHomes ?? 0), 0), [events]);
return { events, loading, error, totalHomes };
}
// Returns a label + color class for how "hot" a storm zone is (conversion likelihood)
export function conversionWindow(dateStr) {
const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000);
if (days < 7) return { label: 'Too Fresh', color: 'text-sky-500', bg: 'bg-sky-50 dark:bg-sky-500/10', border: 'border-sky-200 dark:border-sky-500/20' };
if (days <= 60) return { label: 'Hot Zone', color: 'text-red-500', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20' };
if (days <= 120) return { label: 'Warm', color: 'text-orange-500', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20' };
return { label: 'Cold', color: 'text-zinc-400', bg: 'bg-zinc-100 dark:bg-zinc-800', border: 'border-zinc-200 dark:border-white/10' };
}