fix(storm-intel): forecast day contrast + range filter upgrade to 36mo + custom date range

- Alert/warn forecast cards: day label, temp, and rain% now use high-contrast red/orange text instead of muted gray — readable in both light and dark mode
- Alert card background slightly more opaque (bg-red-100/dark:bg-red-500/15) for better definition
- Range chips: 12 Mo / 18 Mo / 24 Mo / 36 Mo replacing old 1m/3m/6m/1y options
- Custom range: CalendarRange chip reveals two date inputs (from/to) with amber focus ring
- useStormEvents: DATE_RANGE_DAYS updated for new keys; custom range filters by customStart/customEnd; allEvents now uses 36m baseline
This commit is contained in:
Satyam-Rastogi
2026-05-18 18:38:36 +05:30
parent ada58fa646
commit 0613c12b68
2 changed files with 81 additions and 27 deletions
+23 -10
View File
@@ -3,9 +3,6 @@
* *
* USE_MOCK = true → returns MOCK_STORM_EVENTS from mockStore (local dev) * USE_MOCK = true → returns MOCK_STORM_EVENTS from mockStore (local dev)
* USE_MOCK = false → calls /api/storm-events which proxies NWS/NOAA * 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 { useState, useEffect, useMemo } from 'react';
@@ -13,11 +10,18 @@ import { MOCK_STORM_EVENTS } from '../data/mockStore';
const USE_MOCK = true; 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 }; 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 [allEvents, setAllEvents] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -34,7 +38,7 @@ export function useStormEvents({ dateRange = '6m', typeFilter = 'all', severityF
await new Promise(r => setTimeout(r, 450)); await new Promise(r => setTimeout(r, 450));
data = MOCK_STORM_EVENTS; data = MOCK_STORM_EVENTS;
} else { } 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`); const res = await fetch(`/api/storm-events?days=${days}&county=Collin`);
if (!res.ok) throw new Error(`Storm events error: ${res.status}`); if (!res.ok) throw new Error(`Storm events error: ${res.status}`);
data = await res.json(); data = await res.json();
@@ -52,10 +56,19 @@ export function useStormEvents({ dateRange = '6m', typeFilter = 'all', severityF
}, [dateRange]); }, [dateRange]);
const events = useMemo(() => { const events = useMemo(() => {
const cutoffMs = Date.now() - (DATE_RANGE_DAYS[dateRange] || 180) * 86400000;
let filtered = allEvents.filter(e => { 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 (typeFilter !== 'all' && e.type !== typeFilter) return false;
if (severityFilter !== 'all' && e.severity !== severityFilter) return false; if (severityFilter !== 'all' && e.severity !== severityFilter) return false;
return true; return true;
@@ -68,7 +81,7 @@ export function useStormEvents({ dateRange = '6m', typeFilter = 'all', severityF
} }
return filtered; 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]); const totalHomes = useMemo(() => events.reduce((s, e) => s + (e.estimatedHomes ?? 0), 0), [events]);
+52 -11
View File
@@ -16,7 +16,7 @@ import {
Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin, Home, AlertTriangle, TrendingUp, Users, Calendar, MapPin,
Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2, Flame, Snowflake, Clock, ArrowRight, Eye, UserPlus, Loader2,
DollarSign, Target, BarChart2, Map, Pencil, Sun, Cloud, DollarSign, Target, BarChart2, Map, Pencil, Sun, Cloud,
CloudRain, CloudDrizzle, CheckCircle, XCircle, CloudRain, CloudDrizzle, CheckCircle, XCircle, CalendarRange,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
@@ -385,20 +385,36 @@ const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => {
key={day.date} key={day.date}
className={`flex-none w-[70px] rounded-xl border px-2 py-1.5 flex flex-col items-center gap-0.5 ${ className={`flex-none w-[70px] rounded-xl border px-2 py-1.5 flex flex-col items-center gap-0.5 ${
isAlert 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 : 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' : 'border-zinc-100 dark:border-white/[0.06] bg-zinc-50/60 dark:bg-zinc-800/30'
}`} }`}
> >
<span className={`text-[10px] font-black uppercase tracking-wide ${i === 0 ? 'text-amber-600 dark:text-amber-400' : 'text-zinc-500 dark:text-zinc-400'}`}> {/* Day label — high contrast on alert cards */}
<span className={`text-[10px] font-black uppercase tracking-wide ${
i === 0
? 'text-amber-600 dark:text-amber-400'
: isAlert
? 'text-red-700 dark:text-red-300'
: isWarn
? 'text-orange-700 dark:text-orange-300'
: 'text-zinc-500 dark:text-zinc-400'
}`}>
{dayLabel} {dayLabel}
</span> </span>
<FIcon <FIcon
size={16} size={16}
style={{ color: isAlert ? '#DC2626' : isWarn ? '#F97316' : '#71717a' }} style={{ color: isAlert ? '#DC2626' : isWarn ? '#F97316' : '#71717a' }}
/> />
<span className={`text-[11px] font-black ${isAlert ? 'text-red-600 dark:text-red-400' : 'text-zinc-700 dark:text-zinc-200'}`}> {/* Temp — white in dark mode on alert so it pops against the colored bg */}
<span className={`text-[11px] font-black ${
isAlert
? 'text-red-700 dark:text-white'
: isWarn
? 'text-orange-700 dark:text-white'
: 'text-zinc-700 dark:text-zinc-200'
}`}>
{day.tempF}° {day.tempF}°
</span> </span>
{/* Storm score bar */} {/* Storm score bar */}
@@ -408,7 +424,9 @@ const ForecastStrip = ({ forecast, loading, alertDay, hasStormAlert }) => {
style={{ width: `${day.stormScore}%`, backgroundColor: scoreColor(day.stormScore) }} style={{ width: `${day.stormScore}%`, backgroundColor: scoreColor(day.stormScore) }}
/> />
</div> </div>
<span className={`text-[9px] font-bold ${isAlert ? 'text-red-500' : isWarn ? 'text-orange-500' : 'text-zinc-400'}`}> <span className={`text-[9px] font-bold ${
isAlert ? 'text-red-700 dark:text-red-300' : isWarn ? 'text-orange-600 dark:text-orange-300' : 'text-zinc-400'
}`}>
{day.precipProb}% rain {day.precipProb}% rain
</span> </span>
</div> </div>
@@ -864,7 +882,9 @@ const StormIntelPage = () => {
const canManageZones = ['ADMIN', 'OWNER'].includes(user?.role); const canManageZones = ['ADMIN', 'OWNER'].includes(user?.role);
const [activeTab, setActiveTab] = useState('map'); 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 [typeFilter, setTypeFilter] = useState('all');
const [severityFilter, setSeverityFilter] = useState('all'); const [severityFilter, setSeverityFilter] = useState('all');
const [sortBy, setSortBy] = useState('date'); const [sortBy, setSortBy] = useState('date');
@@ -879,8 +899,8 @@ const StormIntelPage = () => {
const [drawingLinkedStormId, setDrawingLinkedStormId] = useState(null); const [drawingLinkedStormId, setDrawingLinkedStormId] = useState(null);
const [showZoneModal, setShowZoneModal] = useState(false); const [showZoneModal, setShowZoneModal] = useState(false);
const { events, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy }); const { events, loading, error, totalHomes } = useStormEvents({ dateRange, typeFilter, severityFilter, sortBy, customStart, customEnd });
const { events: allEvents } = useStormEvents({ dateRange: '1y' }); const { events: allEvents } = useStormEvents({ dateRange: '36m' });
const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast(); const { forecast, loading: forecastLoading, alertDay, hasStormAlert } = useStormForecast();
const handleSelect = useCallback((storm) => { 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'}`} 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'}`}
> >
<div className="px-4 sm:px-6 py-3 flex flex-wrap gap-x-6 gap-y-2 items-center"> <div className="px-4 sm:px-6 py-3 flex flex-wrap gap-x-6 gap-y-2 items-center">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Range</span> <span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Range</span>
{[['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]) => (
<FilterChip key={val} active={dateRange === val} onClick={() => setDateRange(val)}>{label}</FilterChip> <FilterChip key={val} active={dateRange === val} onClick={() => setDateRange(val)}>{label}</FilterChip>
))} ))}
<FilterChip active={dateRange === 'custom'} onClick={() => setDateRange('custom')}>
<span className="flex items-center gap-1"><CalendarRange size={11} />Custom</span>
</FilterChip>
</div> </div>
{/* Custom date inputs */}
{dateRange === 'custom' && (
<div className="flex items-center gap-2 flex-wrap">
<input
type="date"
value={customStart}
onChange={e => 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"
/>
<span className="text-[11px] text-zinc-400 font-medium">to</span>
<input
type="date"
value={customEnd}
onChange={e => 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"
/>
</div>
)}
<div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" /> <div className="w-px h-5 bg-zinc-200 dark:bg-white/10 hidden sm:block" />
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Severity</span> <span className="text-[11px] font-bold text-zinc-400 uppercase tracking-wider mr-1">Severity</span>