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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user