feat(storm-intel): Phase 4+5 — Tomorrow.io forecast overlay and canvasser zone assignment

- Tomorrow.io 7-day forecast strip with weather icons, storm score bar, and per-day risk color coding
- High-risk alert banner in forecast strip when any day scores >=65
- api/storm-forecast.js: Vercel proxy that reads TOMORROW_API_KEY server-side; graceful empty response when key unset
- useStormForecast hook: USE_MOCK=false with realistic MOCK_FORECAST fallback
- ZoneDrawingLayer: click-to-place polygon vertices using react-leaflet useMapEvents; disables double-click zoom while active; shows live vertex dots + dashed preview polygon
- ZoneLayer: renders stormZones as colored semi-transparent polygons with agent initials at centroid
- ZoneAssignmentModal: name + canvasser picker + color selector + optional storm link; portaled modal
- StormIntelPage: Draw Zone button (Admin/Owner only); drawing state/cancel flow; stormZones displayed on map; zone summary strip in sidebar; StormDetailDrawer has working "Draw Canvasser Zone" action
- FieldStormZonePage: canvasser mobile view at /field/storm-zones showing assigned zones with map, linked storm details, conversion window badge, and peak window alert
- Layout: Storm Zones entry in FIELD_AGENT nav
- App: /field/storm-zones route (FIELD_AGENT + Admin + Owner)
This commit is contained in:
Satyam-Rastogi
2026-05-18 18:06:17 +05:30
parent df50f27ba6
commit 12bd2fe0de
8 changed files with 1199 additions and 115 deletions
+68
View File
@@ -0,0 +1,68 @@
/**
* Tomorrow.io 7-day weather forecast for Plano TX.
*
* USE_MOCK = true → realistic mock forecast (no API call needed locally)
* USE_MOCK = false → calls /api/storm-forecast which proxies Tomorrow.io
*
* Flip to false once TOMORROW_API_KEY is set in Vercel env vars.
* For local dev with vercel dev, TOMORROW_API_KEY in .env is picked up automatically.
*/
import { useState, useEffect } from 'react';
const USE_MOCK = false;
// Realistic mock — includes a thunderstorm day and a hail-risk day
const MOCK_FORECAST = [
{ date: '2026-05-18T06:00:00Z', precipProb: 12, weatherCode: 1000, weatherLabel: 'Clear', hailRisk: false, windGust: 14, windSpeed: 9, tempF: 83, humidity: 42, stormScore: 6 },
{ date: '2026-05-19T06:00:00Z', precipProb: 28, weatherCode: 4200, weatherLabel: 'Light Rain', hailRisk: false, windGust: 22, windSpeed: 14, tempF: 79, humidity: 58, stormScore: 14 },
{ date: '2026-05-20T06:00:00Z', precipProb: 78, weatherCode: 8000, weatherLabel: 'Thunderstorm', hailRisk: true, windGust: 48, windSpeed: 28, tempF: 74, humidity: 72, stormScore: 88 },
{ date: '2026-05-21T06:00:00Z', precipProb: 52, weatherCode: 4001, weatherLabel: 'Rain', hailRisk: false, windGust: 30, windSpeed: 18, tempF: 71, humidity: 68, stormScore: 34 },
{ date: '2026-05-22T06:00:00Z', precipProb: 18, weatherCode: 1001, weatherLabel: 'Cloudy', hailRisk: false, windGust: 16, windSpeed: 10, tempF: 76, humidity: 52, stormScore: 9 },
{ date: '2026-05-23T06:00:00Z', precipProb: 8, weatherCode: 1000, weatherLabel: 'Clear', hailRisk: false, windGust: 12, windSpeed: 7, tempF: 81, humidity: 38, stormScore: 4 },
{ date: '2026-05-24T06:00:00Z', precipProb: 42, weatherCode: 7102, weatherLabel: 'Light Hail', hailRisk: true, windGust: 35, windSpeed: 22, tempF: 76, humidity: 61, stormScore: 71 },
];
export function useStormForecast() {
const [forecast, setForecast] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
const load = async () => {
try {
let data;
if (USE_MOCK) {
await new Promise(r => setTimeout(r, 350));
data = MOCK_FORECAST;
} else {
const res = await fetch('/api/storm-forecast');
if (!res.ok) throw new Error(`Forecast error: ${res.status}`);
data = await res.json();
// Fall back to mock if API returns nothing (key not set yet)
if (!Array.isArray(data) || data.length === 0) data = MOCK_FORECAST;
}
if (!cancelled) setForecast(data);
} catch (e) {
if (!cancelled) {
setError(e.message);
setForecast(MOCK_FORECAST); // graceful fallback
}
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, []);
// Highest-risk day in the next 7 days
const alertDay = forecast.reduce((best, d) => (!best || d.stormScore > best.stormScore) ? d : best, null);
const hasStormAlert = alertDay?.stormScore >= 65;
return { forecast, loading, error, alertDay, hasStormAlert };
}