diff --git a/src/hooks/useStormEvents.js b/src/hooks/useStormEvents.js index d6a97c9..e375b6e 100644 --- a/src/hooks/useStormEvents.js +++ b/src/hooks/useStormEvents.js @@ -3,9 +3,6 @@ * * 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'; @@ -13,14 +10,21 @@ import { MOCK_STORM_EVENTS } from '../data/mockStore'; const USE_MOCK = true; -const DATE_RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365 }; +const DATE_RANGE_DAYS = { '12m': 365, '18m': 548, '24m': 730, '36m': 1095 }; const SEVERITY_ORDER = { severe: 4, significant: 3, moderate: 2, trace: 1 }; -export function useStormEvents({ dateRange = '6m', typeFilter = 'all', severityFilter = 'all', sortBy = 'date' } = {}) { +export function useStormEvents({ + dateRange = '12m', + typeFilter = 'all', + severityFilter = 'all', + sortBy = 'date', + customStart = '', + customEnd = '', +} = {}) { const [allEvents, setAllEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { let cancelled = false; @@ -34,7 +38,7 @@ export function useStormEvents({ dateRange = '6m', typeFilter = 'all', severityF await new Promise(r => setTimeout(r, 450)); data = MOCK_STORM_EVENTS; } else { - const days = DATE_RANGE_DAYS[dateRange] || 180; + const days = DATE_RANGE_DAYS[dateRange] ?? 365; 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(); @@ -52,10 +56,19 @@ export function useStormEvents({ dateRange = '6m', typeFilter = 'all', severityF }, [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; + const d = new Date(e.date); + + // Custom date range + if (dateRange === 'custom') { + const start = customStart ? new Date(customStart) : new Date(0); + const end = customEnd ? new Date(customEnd + 'T23:59:59') : new Date(); + if (d < start || d > end) return false; + } else { + const days = DATE_RANGE_DAYS[dateRange] ?? 365; + if (d.getTime() < Date.now() - days * 86400000) return false; + } + if (typeFilter !== 'all' && e.type !== typeFilter) return false; if (severityFilter !== 'all' && e.severity !== severityFilter) return false; return true; @@ -68,7 +81,7 @@ export function useStormEvents({ dateRange = '6m', typeFilter = 'all', severityF } return filtered; - }, [allEvents, dateRange, typeFilter, severityFilter, sortBy]); + }, [allEvents, dateRange, typeFilter, severityFilter, sortBy, customStart, customEnd]); const totalHomes = useMemo(() => events.reduce((s, e) => s + (e.estimatedHomes ?? 0), 0), [events]); @@ -78,8 +91,8 @@ export function useStormEvents({ dateRange = '6m', typeFilter = 'all', severityF // 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' }; + 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' }; } diff --git a/src/pages/StormIntelPage.jsx b/src/pages/StormIntelPage.jsx index 07753db..501383b 100644 --- a/src/pages/StormIntelPage.jsx +++ b/src/pages/StormIntelPage.jsx @@ -16,7 +16,7 @@ import { Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin, Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2, DollarSign, Target, BarChart2, Map, Pencil, Sun, Cloud, - CloudRain, CloudDrizzle, CheckCircle, XCircle, + CloudRain, CloudDrizzle, CheckCircle, XCircle, CalendarRange, } from 'lucide-react'; import { toast } from 'sonner'; import { useTheme } from '../context/ThemeContext'; @@ -385,20 +385,36 @@ const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => { key={day.date} className={`flex-none w-[70px] rounded-xl border px-2 py-1.5 flex flex-col items-center gap-0.5 ${ isAlert - ? 'border-red-200 dark:border-red-500/20 bg-red-50/60 dark:bg-red-500/8' + ? 'border-red-300 dark:border-red-500/30 bg-red-100 dark:bg-red-500/15' : isWarn - ? 'border-orange-200 dark:border-orange-500/20 bg-orange-50/60 dark:bg-orange-500/8' + ? 'border-orange-200 dark:border-orange-500/20 bg-orange-50/60 dark:bg-orange-500/10' : 'border-zinc-100 dark:border-white/[0.06] bg-zinc-50/60 dark:bg-zinc-800/30' }`} > - + {/* 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 */} @@ -408,7 +424,9 @@ const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => { style={{ width: `${day.stormScore}%`, backgroundColor: scoreColor(day.stormScore) }} /> - + {day.precipProb}% rain @@ -864,7 +882,9 @@ const StormIntelPage = () => { const canManageZones = ['ADMIN', 'OWNER'].includes(user?.role); const [activeTab, setActiveTab] = useState('map'); - const [dateRange, setDateRange] = useState('6m'); + 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'); @@ -879,8 +899,8 @@ const StormIntelPage = () => { const [drawingLinkedStormId, setDrawingLinkedStormId] = useState(null); const [showZoneModal, setShowZoneModal] = useState(false); - const { events, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy }); - const { events: allEvents } = useStormEvents({ dateRange: '1y' }); + 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(); const handleSelect = useCallback((storm) => { @@ -1062,12 +1082,33 @@ const StormIntelPage = () => { className={`shrink-0 border-b border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-zinc-900/60 ${showFilters || window.innerWidth >= 1024 ? '' : 'hidden lg:flex'}`} >
-
+
Range - {[['1m', '1 Month'], ['3m', '3 Months'], ['6m', '6 Months'], ['1y', '1 Year']].map(([val, label]) => ( + {[['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" + /> +
+ )}
Severity