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
+117
View File
@@ -0,0 +1,117 @@
/**
* Proxies Tomorrow.io daily weather forecast for the Plano TX area.
* Set TOMORROW_API_KEY in Vercel env vars (and .env locally for vercel dev).
*
* Response: array of 7-day daily forecast objects, normalised for the frontend.
* Cached 1 hour — free tier allows 500 calls/day.
*/
const PLANO_LAT = 33.055;
const PLANO_LNG = -96.752;
// Tomorrow.io weather code → label + hail risk flag
function interpretCode(code) {
const map = {
1000: { label: 'Clear', hailRisk: false },
1001: { label: 'Cloudy', hailRisk: false },
1100: { label: 'Mostly Clear', hailRisk: false },
1101: { label: 'Partly Cloudy', hailRisk: false },
1102: { label: 'Mostly Cloudy', hailRisk: false },
2000: { label: 'Fog', hailRisk: false },
4000: { label: 'Drizzle', hailRisk: false },
4001: { label: 'Rain', hailRisk: false },
4200: { label: 'Light Rain', hailRisk: false },
4201: { label: 'Heavy Rain', hailRisk: false },
5000: { label: 'Snow', hailRisk: false },
5001: { label: 'Flurries', hailRisk: false },
5100: { label: 'Light Snow', hailRisk: false },
5101: { label: 'Heavy Snow', hailRisk: false },
6000: { label: 'Freezing Drizzle', hailRisk: true },
6001: { label: 'Freezing Rain', hailRisk: true },
6200: { label: 'Lt Freezing Rain', hailRisk: true },
6201: { label: 'Hvy Freezing Rain',hailRisk: true },
7000: { label: 'Ice Pellets', hailRisk: true },
7101: { label: 'Heavy Hail', hailRisk: true },
7102: { label: 'Light Hail', hailRisk: true },
8000: { label: 'Thunderstorm', hailRisk: true },
};
return map[code] ?? { label: 'Mixed', hailRisk: false };
}
export default async function handler(req, res) {
res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate=7200');
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const key = process.env.TOMORROW_API_KEY;
if (!key) {
console.warn('[storm-forecast] TOMORROW_API_KEY not set');
return res.status(200).json([]);
}
try {
const fields = [
'precipitationProbability',
'weatherCode',
'windSpeedAvg',
'windGustMax',
'temperatureAvg',
'precipitationIntensityAvg',
'humidityAvg',
].join(',');
const url = `https://api.tomorrow.io/v4/weather/forecast?location=${PLANO_LAT},${PLANO_LNG}&apikey=${key}&timesteps=1d&fields=${fields}`;
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!resp.ok) {
const txt = await resp.text();
console.warn('[storm-forecast] Tomorrow.io error', resp.status, txt);
return res.status(200).json([]);
}
const data = await resp.json();
const daily = data?.timelines?.daily ?? [];
const forecast = daily.slice(0, 7).map(day => {
const v = day.values ?? {};
// Daily aggregated fields may come back as plain names or with Avg/Max suffix
const precipProb = v.precipitationProbability ?? v.precipitationProbabilityAvg ?? 0;
const weatherCode = v.weatherCode ?? v.weatherCodeMax ?? 1000;
const windGust = v.windGustMax ?? v.windGustAvg ?? 0;
const windSpeed = v.windSpeedAvg ?? 0;
const temp = v.temperatureAvg ?? 0;
const humidity = v.humidityAvg ?? 0;
const { label, hailRisk } = interpretCode(weatherCode);
// Storm severity: 0100 score for how dangerous this day looks
const stormScore = Math.min(100, Math.round(
precipProb * 0.5 +
(hailRisk ? 35 : 0) +
(windGust > 40 ? 15 : windGust > 25 ? 8 : 0)
));
return {
date: day.time,
precipProb: Math.round(precipProb),
weatherCode,
weatherLabel: label,
hailRisk,
windGust: Math.round(windGust),
windSpeed: Math.round(windSpeed),
tempF: Math.round(temp),
humidity: Math.round(humidity),
stormScore,
};
});
return res.status(200).json(forecast);
} catch (err) {
console.error('[storm-forecast] error:', err.message);
return res.status(200).json([]);
}
}