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
+95
View File
@@ -0,0 +1,95 @@
/**
* Proxies active NWS severe weather alerts for Texas to the frontend.
* No auth required — api.weather.gov is a free public endpoint.
*
* For historical data beyond 7 days, integrate NOAA Storm Events CSV API
* or SWDI (https://www.ncei.noaa.gov/pub/data/swdi/) in a future iteration.
*
* Response: array of normalised storm event objects (same shape as MOCK_STORM_EVENTS)
*/
const NWS_HEADERS = {
'User-Agent': 'PlanoRealtyCRM/1.0 (admin@plano-realty.com)',
Accept: 'application/geo+json',
};
function severityFromSize(inches) {
if (!inches || inches < 0.75) return 'none';
if (inches < 1.00) return 'trace';
if (inches < 1.50) return 'moderate';
if (inches < 2.00) return 'significant';
return 'severe';
}
function parseHailSize(text = '') {
const m = text.match(/(\d+\.?\d*)\s*inch/i) || text.match(/(\d+\.?\d*)"/) ;
return m ? parseFloat(m[1]) : null;
}
export default async function handler(req, res) {
res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate=86400');
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// NWS active severe weather alerts for TX — hail + thunderstorm
const url = 'https://api.weather.gov/alerts/active?area=TX&event=Hail,Severe+Thunderstorm,Tornado';
const nwsRes = await fetch(url, { headers: NWS_HEADERS });
if (!nwsRes.ok) {
console.warn('[storm-events] NWS returned', nwsRes.status);
return res.status(200).json([]);
}
const geojson = await nwsRes.json();
const features = (geojson.features || []);
const events = features
.filter(f => {
const evt = (f.properties?.event || '').toLowerCase();
return evt.includes('hail') || evt.includes('thunderstorm') || evt.includes('tornado');
})
.map((f, i) => {
const p = f.properties || {};
const desc = p.description || p.headline || '';
const hailSize = parseHailSize(desc) ?? parseHailSize(p.parameters?.hailSize?.[0] ?? '');
// NWS GeoJSON polygon coordinates are [lng, lat] — swap for Leaflet
let polygon = [];
const geom = f.geometry;
if (geom?.type === 'Polygon' && geom.coordinates?.[0]) {
polygon = geom.coordinates[0].map(([lng, lat]) => [lat, lng]);
} else if (geom?.type === 'MultiPolygon' && geom.coordinates?.[0]?.[0]) {
polygon = geom.coordinates[0][0].map(([lng, lat]) => [lat, lng]);
}
const typeStr = (p.event || '').toLowerCase();
const type = typeStr.includes('tornado') ? 'tornado'
: typeStr.includes('hail') ? 'hail'
: 'wind';
return {
id: `NWS-${p.id || i}`,
date: p.onset || p.sent || new Date().toISOString(),
type,
severity: severityFromSize(hailSize),
maxHailSize: hailSize,
areaName: p.areaDesc || 'Unknown Area',
county: 'Collin',
estimatedHomes: null,
description: p.headline || desc.slice(0, 300),
source: 'NWS',
polygon,
};
})
.filter(e => e.polygon.length >= 3); // require a valid polygon
return res.status(200).json(events);
} catch (err) {
console.error('[storm-events] error:', err.message);
return res.status(200).json([]);
}
}