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,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}×teps=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: 0–100 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([]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user