import React, { useState, useCallback, useRef, useEffect, memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
MapContainer, TileLayer, Polygon, Polyline, CircleMarker,
Circle, Tooltip, useMap, useMapEvents,
} from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import {
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
ResponsiveContainer, CartesianGrid, Cell,
} from 'recharts';
import {
CloudLightning, Wind, Zap, Droplets, Filter, X, ChevronRight,
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
DollarSign, Target, BarChart2, Map, Pencil, Sun, Cloud,
CloudRain, CloudDrizzle, CheckCircle, XCircle, CalendarRange,
Search, Crosshair, CloudSnow, CloudHail, Navigation,
} from 'lucide-react';
import { toast } from 'sonner';
import { useTheme } from '../context/ThemeContext';
import { useMockStore } from '../data/mockStore';
import { useStormEvents, conversionWindow } from '../hooks/useStormEvents';
import { useStormAttribution } from '../hooks/useStormAttribution';
import { useStormForecast } from '../hooks/useStormForecast';
import ZoneAssignmentModal from '../components/storm/ZoneAssignmentModal';
import { useAuth } from '../context/AuthContext';
// ─── Constants ───────────────────────────────────────────────────────────────
const PLANO_CENTER = [33.055, -96.752];
const DEFAULT_ZOOM = 11;
const SEVERITY_STYLES = {
severe: { fill: '#DC2626', stroke: '#991B1B', fillOpacity: 0.28, weight: 2.5, text: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20', label: 'Severe', dot: '#DC2626' },
significant: { fill: '#EF4444', stroke: '#DC2626', fillOpacity: 0.22, weight: 2, text: 'text-red-500 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-500/10', border: 'border-red-200 dark:border-red-500/20', label: 'Significant', dot: '#EF4444' },
moderate: { fill: '#F97316', stroke: '#EA580C', fillOpacity: 0.20, weight: 2, text: 'text-orange-500 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20', label: 'Moderate', dot: '#F97316' },
trace: { fill: '#EAB308', stroke: '#CA8A04', fillOpacity: 0.15, weight: 1.5, text: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-500/10', border: 'border-yellow-200 dark:border-yellow-500/20', label: 'Trace', dot: '#EAB308' },
};
const TYPE_ICONS = {
hail: CloudHail,
wind: Wind,
tornado: Zap,
flood: Droplets,
rain: CloudRain,
snow: CloudSnow,
ice: CloudSnow,
thunderstorm: CloudLightning,
};
const TYPE_LABELS = {
hail: 'Hail', wind: 'Wind', tornado: 'Tornado', flood: 'Flood',
rain: 'Rain', snow: 'Snow', ice: 'Ice Storm', thunderstorm: 'Thunderstorm',
};
const FORECAST_ICONS = {
1000: Sun, 1100: Sun,
1001: Cloud, 1101: Cloud, 1102: Cloud,
4000: CloudDrizzle, 4200: CloudDrizzle,
4001: CloudRain, 4201: CloudRain,
8000: CloudLightning,
7000: Snowflake, 7101: Snowflake, 7102: Snowflake,
5000: Snowflake, 5100: Snowflake, 5101: Snowflake,
};
function scoreColor(score) {
if (score >= 65) return '#DC2626';
if (score >= 40) return '#F97316';
if (score >= 20) return '#EAB308';
return '#10B981';
}
function haversine(lat1, lon1, lat2, lon2) {
const R = 3958.8;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function polygonCentroid(polygon) {
const n = polygon.length;
return [
polygon.reduce((s, p) => s + p[0], 0) / n,
polygon.reduce((s, p) => s + p[1], 0) / n,
];
}
const MAP_STYLES = `
.storm-intel-map .leaflet-container { background: #e8e0d8; font-family: system-ui, sans-serif; }
.storm-tooltip { background: white !important; border: 1px solid rgba(0,0,0,0.08) !important; border-radius: 10px !important; box-shadow: 0 4px 14px rgba(0,0,0,0.12) !important; padding: 8px 10px !important; pointer-events: none !important; }
.storm-tooltip::before { display: none !important; }
.draw-cursor .leaflet-container { cursor: crosshair !important; }
`;
// ─── Map sub-components ───────────────────────────────────────────────────────
const MapFlyTo = ({ storm, flyCount }) => {
const map = useMap();
const prevCount = useRef(0);
useEffect(() => {
const invalidate = () => map.invalidateSize({ animate: false });
const t1 = setTimeout(invalidate, 60);
const t2 = setTimeout(invalidate, 340);
window.addEventListener('resize', invalidate);
return () => { clearTimeout(t1); clearTimeout(t2); window.removeEventListener('resize', invalidate); };
}, [map]);
useEffect(() => {
if (!storm?.polygon?.length || flyCount === prevCount.current) return;
prevCount.current = flyCount;
try {
const bounds = L.latLngBounds(storm.polygon);
map.fitBounds(bounds, { padding: [60, 60], maxZoom: 14, animate: true, duration: 0.7 });
} catch { /* malformed polygon */ }
}, [storm, flyCount, map]);
return null;
};
const ZoneLayer = ({ zones }) => {
const map = useMap();
if (!zones?.length) return null;
const flyToZone = (polygon) => {
try {
const bounds = L.latLngBounds(polygon);
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15, animate: true, duration: 0.7 });
} catch { /* malformed polygon */ }
};
return zones.map(zone => {
if (!zone.polygon?.length) return null;
const centroid = zone.polygon.reduce(
(acc, pt) => [acc[0] + pt[0] / zone.polygon.length, acc[1] + pt[1] / zone.polygon.length],
[0, 0]
);
return (
flyToZone(zone.polygon) }}
>
{zone.name}
{zone.assignedAgentName} · {zone.status === 'completed' ? 'Completed' : 'Active'}
{/* Agent initials circle at centroid — also clickable */}
flyToZone(zone.polygon) }}
>
{zone.name}
);
});
};
const ZoneDrawingLayer = ({ draftPolygon, onAddPoint, onFinish }) => {
const map = useMap();
useEffect(() => {
map.doubleClickZoom.disable();
map.getContainer().classList.add('draw-cursor');
return () => {
map.doubleClickZoom.enable();
map.getContainer().classList.remove('draw-cursor');
};
}, [map]);
useMapEvents({
click(e) {
onAddPoint([e.latlng.lat, e.latlng.lng]);
},
dblclick() {
if (draftPolygon.length >= 3) onFinish();
},
});
if (draftPolygon.length === 0) return null;
const closedPath = draftPolygon.length > 1
? [...draftPolygon, draftPolygon[0]]
: draftPolygon;
return (
<>
{/* Fill preview */}
{draftPolygon.length >= 3 && (
)}
{/* Vertex dots */}
{draftPolygon.map((pt, i) => (
))}
>
);
};
const PinDropLayer = ({ onDrop }) => {
useMapEvents({ click: e => onDrop([e.latlng.lat, e.latlng.lng]) });
return null;
};
const PIN_RADIUS_M = 8047; // 5 miles in metres
const StormMap = memo(({
events, selectedId, pinnedIds, onSelect, theme, flyCount, selectedStorm,
stormZones, isDrawing, draftPolygon, onAddPoint, onFinishPolygon,
pinDropMode, pins, onDropPin,
}) => {
return (
{/* Storm polygons — only selected + pinned (or all if none active) */}
{events.map(storm => {
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
const isSelected = storm.id === selectedId;
const isPinned = pinnedIds?.has(storm.id);
return (
!isDrawing && !pinDropMode && onSelect(storm),
mouseover: (e) => { e.target.setStyle({ fillOpacity: Math.min(sty.fillOpacity + 0.1, 0.4), weight: sty.weight + 1 }); },
mouseout: (e) => {
if (storm.id === selectedId) return;
e.target.setStyle({
fillOpacity: isPinned ? Math.min(sty.fillOpacity + 0.06, 0.36) : sty.fillOpacity,
weight: isPinned ? sty.weight + 0.5 : sty.weight,
});
},
}}
>
{sty.label} Hail
{storm.maxHailSize && (
{storm.maxHailSize}"
)}
{storm.areaName}
{Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000)}d ago · ~{(storm.estimatedHomes ?? '?').toLocaleString()} homes
);
})}
{/* Canvasser zones layer */}
{/* Pin drop circles */}
{pins?.map((pin, i) => (
Pin {i + 1} · 5 mi radius
))}
{/* Pin drop capture layer */}
{pinDropMode && }
{/* Drawing layer (active only when isDrawing) */}
{isDrawing && (
)}
{/* Legend */}
Severity
{Object.entries(SEVERITY_STYLES).map(([key, sty]) => (
))}
{/* Storm count badge */}
{events.length} storm{events.length !== 1 ? 's' : ''}
{/* Drawing instructions */}
{isDrawing && (
{draftPolygon.length < 3
? `Click to place vertices (${draftPolygon.length}/3 min)`
: `Double-click to close polygon (${draftPolygon.length} pts)`
}
)}
{/* Pin drop instruction */}
{pinDropMode && (
Click map to drop a pin (5 mi radius)
)}
);
});
StormMap.displayName = 'StormMap';
// ─── Forecast strip ───────────────────────────────────────────────────────────
const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => {
if (loading) {
return (
{Array.from({ length: 7 }).map((_, i) => (
))}
);
}
if (!forecast.length) return null;
return (
{/* Storm alert banner */}
{hasStormAlert && alertDay && (
High-Risk Storm Day
{new Date(alertDay.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
{' — '}{alertDay.weatherLabel} · {alertDay.windGust} mph gusts · {alertDay.precipProb}% rain
Score {alertDay.stormScore}
)}
{/* 7-day forecast row */}
7-Day
{forecast.map((day, i) => {
const FIcon = FORECAST_ICONS[day.weatherCode] ?? Cloud;
const isAlert = day.stormScore >= 65;
const isWarn = day.stormScore >= 40 && !isAlert;
const dayLabel = i === 0
? 'Today'
: new Date(day.date).toLocaleDateString('en-US', { weekday: 'short' });
return (
{/* Day label — high contrast on alert cards */}
{dayLabel}
{/* Temp — white in dark mode on alert so it pops against the colored bg */}
{day.tempF}°
{/* Storm score bar */}
{day.precipProb}% rain
);
})}
);
};
// ─── Storm card ───────────────────────────────────────────────────────────────
const StormCard = ({ storm, isSelected, isPinned, onSelect, onTogglePin, distanceMiles }) => {
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
const conv = conversionWindow(storm.date);
const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning;
const days = Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000);
const dateLabel = new Date(storm.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return (
onSelect(storm)}
className={`w-full text-left rounded-xl border transition-all duration-150 overflow-hidden ${
isSelected
? 'border-amber-400 dark:border-amber-500 bg-amber-50/60 dark:bg-amber-500/5 shadow-md'
: isPinned
? 'border-amber-300/60 dark:border-amber-500/30 bg-amber-50/30 dark:bg-amber-500/[0.03]'
: 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-900/60 hover:border-zinc-300 dark:hover:border-white/15 hover:shadow-sm'
}`}
>
{storm.areaName}
{distanceMiles != null && (
{distanceMiles.toFixed(1)} mi
)}
{days === 0 ? 'Today' : `${days}d ago`}
{/* Pin button */}
{ e.stopPropagation(); onTogglePin(storm.id); }}
title={isPinned ? 'Unpin from map' : 'Pin to map'}
className={`ml-0.5 p-1 rounded-md transition-colors ${
isPinned
? 'text-amber-500 bg-amber-50 dark:bg-amber-500/10'
: 'text-zinc-300 dark:text-zinc-600 hover:text-amber-400 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10'
}`}
>
{dateLabel}
{sty.label}
{storm.maxHailSize && (
{storm.maxHailSize}"
)}
{conv.label}
{storm.estimatedHomes && (
~{storm.estimatedHomes.toLocaleString()} homes affected
)}
);
};
// ─── Detail drawer ────────────────────────────────────────────────────────────
const StormDetailDrawer = ({ storm, open, onClose, onFlyTo, onAssignZone, canManageZones }) => {
const navigate = useNavigate();
const { theme } = useTheme();
if (!storm) return null;
const sty = SEVERITY_STYLES[storm.severity] ?? SEVERITY_STYLES.trace;
const conv = conversionWindow(storm.date);
const TypeIcon = TYPE_ICONS[storm.type] ?? CloudLightning;
const days = Math.floor((Date.now() - new Date(storm.date).getTime()) / 86400000);
const dateLabel = new Date(storm.date).toLocaleDateString('en-US', { weekday: 'short', month: 'long', day: 'numeric', year: 'numeric' });
const handleCreateLead = () => {
navigate('/emp/fa/leads/new', {
state: {
stormSource: {
id: storm.id,
date: storm.date,
areaName: storm.areaName,
maxHailSize: storm.maxHailSize,
severity: storm.severity,
},
},
});
};
return (
<>
{open && (
)}
{open && (
{sty.label} {storm.type === 'hail' ? 'Hail' : storm.type}
{dateLabel}
{storm.areaName}
{storm.county} County, TX
{conv.label === 'Hot Zone' &&
}
{conv.label === 'Warm' &&
}
{conv.label === 'Cold' &&
}
{conv.label === 'Too Fresh' &&
}
{conv.label}
{conv.label === 'Hot Zone' && 'Peak canvassing window — highest conversion likelihood'}
{conv.label === 'Warm' && 'Still worth canvassing — moderate conversion rate'}
{conv.label === 'Cold' && 'Low conversion — storm damage assessment mostly done'}
{conv.label === 'Too Fresh' && 'Too recent — wait for claims process to begin'}
{storm.maxHailSize && (
Max Hail
{storm.maxHailSize}"
{sty.label} category
)}
{storm.estimatedHomes && (
Est. Homes
{storm.estimatedHomes.toLocaleString()}
in impact zone
)}
Days Ago
{days}
since impact
Source
{storm.source}
data origin
Intel
{storm.description}
Zoom to zone on map
Create Lead from Zone
{canManageZones ? (
{ onClose(); onAssignZone(storm.id); }}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-bold text-[13px] text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 hover:bg-amber-100 dark:hover:bg-amber-500/20 transition-colors active:scale-[0.98]"
>
Draw Canvasser Zone
) : (
navigate('/admin/dispatch')}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-bold text-[13px] text-zinc-700 dark:text-zinc-200 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors active:scale-[0.98]"
>
Open in LynkDispatch
)}
)}
>
);
};
// ─── Attribution view ────────────────────────────────────────────────────────
const SEVERITY_DOT = { severe: '#DC2626', significant: '#EF4444', moderate: '#F97316', trace: '#EAB308' };
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
{label}
{payload.map(p => (
))}
);
};
const AttributionView = ({ events, sessionLeads }) => {
const { theme } = useTheme();
const { summary, perStorm } = useStormAttribution(events, sessionLeads);
const isDark = theme === 'dark';
const maxRevenue = Math.max(...perStorm.map(p => p.revenue), 1);
const chartData = perStorm
.filter(p => p.leadsCount > 0)
.map(p => ({
name: (p.storm.areaName ?? 'Unknown').split('/')[0].trim().replace('Plano', '').trim() || p.storm.areaName,
Leads: p.leadsCount,
Closed: p.closedCount,
stormId: p.storm.id,
severity: p.storm.severity,
}));
const statCards = [
{ label: 'Storm Leads', value: summary.totalLeads, icon: Users, color: '#3B82F6' },
{ label: 'Closed', value: summary.closedLeads, icon: Target, color: '#10B981' },
{ label: 'Conv. Rate', value: `${summary.conversionRate}%`, icon: TrendingUp, color: '#F59E0B' },
{ label: 'Revenue', value: `$${(summary.totalRevenue / 1000).toFixed(0)}k`, icon: DollarSign, color: '#8B5CF6' },
{ label: 'Pipeline', value: `$${(summary.pipelineValue / 1000).toFixed(0)}k`, icon: BarChart2, color: '#F97316' },
{ label: 'Avg Deal', value: `$${(summary.avgDealSize / 1000).toFixed(1)}k`, icon: CloudLightning, color: '#EC4899' },
];
return (
{statCards.map(({ label, value, icon: Icon, color }) => (
))}
Leads Generated Per Storm
} cursor={{ fill: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)' }} />
{chartData.map((entry, i) => (
|
))}
Storm Revenue Breakdown
{perStorm.map((item, idx) => {
const sty = SEVERITY_STYLES[item.storm.severity] ?? SEVERITY_STYLES.trace;
const revenuePercent = item.revenue > 0 ? Math.round((item.revenue / maxRevenue) * 100) : 0;
const stormDate = item.storm.date
? new Date(item.storm.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
#{idx + 1}
{item.storm.areaName ?? 'Unknown Zone'}
{sty.label}
{stormDate}{item.storm.maxHailSize ? ` · ${item.storm.maxHailSize}"` : ''}
{item.revenue > 0 ? `$${item.revenue.toLocaleString()}` : '—'}
revenue
{[
{ label: 'Leads', value: item.leadsCount, color: null },
{ label: 'Closed', value: item.closedCount, color: '#10B981' },
{ label: 'Appointed', value: item.appointedCount, color: '#F59E0B' },
{ label: 'Conv %', value: `${item.conversionRate}%`, color: null },
{ label: 'Pipeline', value: item.pipeline > 0 ? `$${(item.pipeline / 1000).toFixed(0)}k` : '—', color: '#F97316' },
].map(({ label, value, color }) => (
))}
{item.revenue > 0 && (
)}
);
})}
{perStorm.length > 0 && perStorm[0].revenue > 0 && (
Top Performing Zone
{perStorm[0].storm.areaName} generated ${perStorm[0].revenue.toLocaleString()} from {perStorm[0].closedCount} closed deal{perStorm[0].closedCount !== 1 ? 's' : ''} ({perStorm[0].conversionRate}% conversion).
{perStorm[0].pipeline > 0 && ` $${(perStorm[0].pipeline / 1000).toFixed(0)}k more in pipeline.`}
)}
);
};
// ─── Filter chip ──────────────────────────────────────────────────────────────
const FilterChip = ({ active, onClick, children }) => (
{children}
);
// ─── Main page ────────────────────────────────────────────────────────────────
const StormIntelPage = () => {
const { theme } = useTheme();
const { user } = useAuth();
const { leads: storeLeads = [], stormZones, addStormZone } = useMockStore();
const canManageZones = ['ADMIN', 'OWNER'].includes(user?.role);
const [activeTab, setActiveTab] = useState('map');
const [dateRange, setDateRange] = useState('12m');
const [customStart, setCustomStart] = useState('');
const [customEnd, setCustomEnd] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [severityFilter, setSeverityFilter] = useState('all');
const [sortBy, setSortBy] = useState('date');
const [searchQuery, setSearchQuery] = useState('');
const [selectedStorm, setSelectedStorm] = useState(null);
const [pinnedIds, setPinnedIds] = useState(new Set());
const [drawerOpen, setDrawerOpen] = useState(false);
const [flyCount, setFlyCount] = useState(0);
const [showFilters, setShowFilters] = useState(false);
// Pin drop — up to 5 pins
const [pinDropMode, setPinDropMode] = useState(false);
const [pins, setPins] = useState([]); // array of [lat,lng]
const MAX_PINS = 5;
// Zone drawing state
const [isDrawing, setIsDrawing] = useState(false);
const [draftPolygon, setDraftPolygon] = useState([]);
const [drawingLinkedStormId, setDrawingLinkedStormId] = useState(null);
const [showZoneModal, setShowZoneModal] = useState(false);
const { events, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy, customStart, customEnd });
const { events: allEvents } = useStormEvents({ dateRange: '36m' });
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
// Text search filter
const displayEvents = useMemo(() => {
if (!searchQuery.trim()) return events;
const q = searchQuery.toLowerCase();
return events.filter(e =>
e.areaName?.toLowerCase().includes(q) ||
e.type?.toLowerCase().includes(q) ||
e.severity?.toLowerCase().includes(q) ||
e.description?.toLowerCase().includes(q)
);
}, [events, searchQuery]);
// Events within 5 miles of ANY dropped pin
const nearbyEvents = useMemo(() => {
if (!pins.length) return [];
const seen = new Set();
const results = [];
for (const event of displayEvents) {
if (!event.polygon?.length) continue;
const [cLat, cLon] = polygonCentroid(event.polygon);
const minDist = Math.min(...pins.map(p => haversine(p[0], p[1], cLat, cLon)));
if (minDist <= 5 && !seen.has(event.id)) {
seen.add(event.id);
results.push({ ...event, _distanceMiles: minDist });
}
}
return results.sort((a, b) => a._distanceMiles - b._distanceMiles);
}, [displayEvents, pins]);
// Sidebar shows nearby events when pins exist, otherwise full filtered list
const sidebarEvents = pins.length > 0 ? nearbyEvents : displayEvents;
// Map shows: when pins exist → all nearby; else → selected+pinned only (or none until pins placed)
const mapEvents = useMemo(() => {
if (pins.length > 0) return nearbyEvents;
if (!selectedStorm && pinnedIds.size === 0) return []; // nothing until pin placed
return displayEvents.filter(e => pinnedIds.has(e.id) || e.id === selectedStorm?.id);
}, [displayEvents, nearbyEvents, pins, selectedStorm, pinnedIds]);
const handleDropPin = useCallback((latlng) => {
setPins(prev => {
if (prev.length >= MAX_PINS) return prev; // cap at 5
return [...prev, latlng];
});
setPinDropMode(false);
}, []);
const handleRemovePin = useCallback((idx) => {
setPins(prev => prev.filter((_, i) => i !== idx));
}, []);
const handleClearPins = useCallback(() => {
setPins([]);
setPinDropMode(false);
}, []);
const handleTogglePin = useCallback((id) => {
setPinnedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
// Map shows only selected + pinned events; if nothing active, show all
const mapEvents = useMemo(() => {
if (!selectedStorm && pinnedIds.size === 0) return events;
return events.filter(e => pinnedIds.has(e.id) || e.id === selectedStorm?.id);
}, [events, selectedStorm, pinnedIds]);
const handleSelect = useCallback((storm) => {
setSelectedStorm(storm);
setDrawerOpen(true);
setFlyCount(c => c + 1);
}, []);
const handleFlyTo = useCallback(() => {
setFlyCount(c => c + 1);
setDrawerOpen(false);
}, []);
const handleClose = useCallback(() => {
setDrawerOpen(false);
}, []);
const handleStartDrawing = useCallback((linkedStormId = null) => {
setIsDrawing(true);
setDraftPolygon([]);
setDrawingLinkedStormId(linkedStormId);
setDrawerOpen(false);
}, []);
const handleAddPoint = useCallback((pt) => {
setDraftPolygon(prev => [...prev, pt]);
}, []);
const handleFinishPolygon = useCallback(() => {
if (draftPolygon.length < 3) {
toast.error('Need at least 3 points to create a zone');
return;
}
setIsDrawing(false);
setShowZoneModal(true);
}, [draftPolygon]);
const handleCancelDrawing = useCallback(() => {
setIsDrawing(false);
setDraftPolygon([]);
setDrawingLinkedStormId(null);
}, []);
const handleSaveZone = useCallback((zoneData) => {
addStormZone({ ...zoneData, polygon: draftPolygon });
setShowZoneModal(false);
setDraftPolygon([]);
setDrawingLinkedStormId(null);
toast.success(`Zone "${zoneData.name}" saved and assigned to ${zoneData.assignedAgentName}`);
}, [addStormZone, draftPolygon]);
// Responsive map height
const [mapHeight, setMapHeight] = useState('320px');
useEffect(() => {
const update = () => {
const w = window.innerWidth;
setMapHeight(w < 640 ? '280px' : w < 768 ? '340px' : w < 1024 ? '400px' : '100%');
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return (
{/* ── Header ── */}
Storm Intel
Plano TX · Collin County
{/* Summary stats */}
{(totalHomes / 1000).toFixed(1)}k
Homes
{events.filter(e => conversionWindow(e.date).label === 'Hot Zone').length}
Hot Zones
{stormZones.length > 0 && (
<>
{stormZones.filter(z => z.status === 'active').length}
Active Zones
>
)}
{/* Draw Zone button — Admin/Owner only */}
{canManageZones && activeTab === 'map' && !isDrawing && (
handleStartDrawing(null)}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 text-[12px] font-bold hover:bg-amber-100 dark:hover:bg-amber-500/20 transition-colors"
>
Draw Zone
)}
{/* Cancel drawing */}
{isDrawing && (
Cancel
)}
{/* Tab switcher */}
setActiveTab('map')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all ${
activeTab === 'map'
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
Map
setActiveTab('attribution')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all ${
activeTab === 'attribution'
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
Attribution
{activeTab === 'map' && (
setShowFilters(f => !f)}
className={`lg:hidden p-2 rounded-lg border transition-colors ${
showFilters
? 'bg-amber-500 border-amber-600 text-white'
: 'border-zinc-200 dark:border-white/10 text-zinc-500 hover:bg-zinc-50 dark:hover:bg-white/5'
}`}
>
)}
{/* ── Filter bar — map tab only ── */}
{activeTab === 'map' && (showFilters || true) && (
= 1024 ? '' : 'hidden lg:flex'}`}
>
Range
{[['1m', '1 Mo'], ['3m', '3 Mo'], ['6m', '6 Mo'], ['12m', '12 Mo'], ['18m', '18 Mo'], ['24m', '24 Mo'], ['36m', '36 Mo']].map(([val, label]) => (
setDateRange(val)}>{label}
))}
setDateRange('custom')}>
Custom
{/* Custom date inputs */}
{dateRange === 'custom' && (
setCustomStart(e.target.value)}
className="px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-white/[0.08] bg-white dark:bg-zinc-800 text-[12px] text-zinc-800 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-amber-500/40 focus:border-amber-400 transition-all"
/>
to
setCustomEnd(e.target.value)}
className="px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-white/[0.08] bg-white dark:bg-zinc-800 text-[12px] text-zinc-800 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-amber-500/40 focus:border-amber-400 transition-all"
/>
)}
Type
{[['all','All'],['hail','Hail'],['tornado','Tornado'],['flood','Flood'],['wind','Wind'],['snow','Snow'],['rain','Rain']].map(([val, label]) => (
setTypeFilter(val)}>{label}
))}
Severity
{[['all', 'All'], ['severe', 'Severe'], ['significant', 'Significant'], ['moderate', 'Moderate'], ['trace', 'Trace']].map(([val, label]) => (
setSeverityFilter(val)}>{label}
))}
Sort
setSortBy('date')}>Newest
setSortBy('severity')}>Severity
)}
{/* ── Forecast strip — map tab only ── */}
{activeTab === 'map' && (
)}
{/* ── Attribution tab ── */}
{activeTab === 'attribution' && (
)}
{/* ── Map tab body ── */}
{/* Map */}
{loading ? (
) : error ? (
Failed to load storm data
{error}
) : pins.length === 0 ? (
/* Show base map with pin prompt overlay */
) : mapEvents.length === 0 ? (
No storms within 5 miles of your pins
) : (
)}
{/* Storm list */}
{/* Sidebar header */}
{pins.length > 0
? <>{nearbyEvents.length} Nearby{pinnedIds.size > 0 && · {pinnedIds.size} pinned }>
: <>{displayEvents.length} Event{displayEvents.length !== 1 ? 's' : ''}{pinnedIds.size > 0 && · {pinnedIds.size} pinned }>
}
{/* Pin drop toggle */}
setPinDropMode(m => !m)}
disabled={pins.length >= MAX_PINS}
title={pins.length >= MAX_PINS ? 'Max 5 pins' : pinDropMode ? 'Cancel pin drop' : 'Drop a pin (5 mi radius)'}
className={`p-1.5 rounded-lg border transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
pinDropMode
? 'bg-violet-600 border-violet-700 text-white'
: 'border-zinc-200 dark:border-white/10 text-zinc-400 hover:text-violet-500 hover:border-violet-300 dark:hover:border-violet-500/40'
}`}
>
{pins.length > 0 && (
Pins
)}
{pinnedIds.size > 0 && (
setPinnedIds(new Set())} className="text-[11px] text-amber-500 hover:text-amber-600 flex items-center gap-0.5 font-bold transition-colors">
Clear
)}
{selectedStorm && (
{ setSelectedStorm(null); setDrawerOpen(false); }} className="text-[11px] text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 flex items-center gap-0.5 transition-colors">
)}
{/* Search */}
{/* Active pins strip */}
{pins.length > 0 && (
{pins.map((pin, i) => (
Pin {i+1}
handleRemovePin(i)} className="text-violet-400 hover:text-violet-600 ml-0.5">
))}
{pins.length < MAX_PINS && (
setPinDropMode(true)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-violet-300 dark:border-violet-500/40 text-[10px] font-bold text-violet-500 hover:bg-violet-100 dark:hover:bg-violet-500/15 transition-colors"
>
+ Add pin
)}
)}
{/* Zone summary strip */}
{stormZones.length > 0 && (
Canvasser Zones
{canManageZones && (
handleStartDrawing(null)}
className="text-[10px] font-bold text-amber-600 dark:text-amber-400 hover:underline flex items-center gap-0.5"
>
New
)}
{stormZones.slice(0, 4).map(zone => (
{zone.name}
{zone.status === 'completed' &&
}
))}
{stormZones.length > 4 && (
+{stormZones.length - 4}
)}
)}
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
))
) : pins.length === 0 ? (
/* No pins yet — prompt user to drop one */
Drop a pin to explore
Click the crosshair button and place a pin on the map to find storm events within 5 miles.
setPinDropMode(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-600 text-white text-[12px] font-bold hover:bg-violet-700 transition-colors"
>
Drop a Pin
) : sidebarEvents.length === 0 ? (
No events within 5 miles of your pins
) : (
sidebarEvents.map(storm => (
))
)}
{/* Detail drawer */}
{/* Zone assignment modal */}
{ setShowZoneModal(false); setDraftPolygon([]); setDrawingLinkedStormId(null); }}
onSave={handleSaveZone}
polygon={draftPolygon}
linkedStormId={drawingLinkedStormId}
events={allEvents}
/>
);
};
export default StormIntelPage;