import { toSeverity } from "../describe"; import { fetchJson, fromUnix, maybe, MAX_DAYS, MAX_HOURS, nextHours, readKey, round1 } from "../http"; import type { Condition, WeatherProvider } from "../types"; /** * WeatherAPI.com — one request returns current, daily, hourly and alerts. * The free plan returns 3 days; paid plans return up to 14. We ask for 14 * and use whatever comes back. * Docs: https://www.weatherapi.com/docs/ */ interface WapiCondition { code: number; text: string; } interface WapiHour { time_epoch: number; temp_c: number; feelslike_c: number; is_day: 0 | 1; condition: WapiCondition; wind_kph: number; wind_degree: number; gust_kph: number; humidity: number; chance_of_rain: number; chance_of_snow: number; precip_mm: number; uv: number; } interface WapiDay { date: string; // "2026-10-21" day: { maxtemp_c: number; mintemp_c: number; maxwind_kph: number; totalprecip_mm: number; avghumidity: number; daily_chance_of_rain: number; daily_chance_of_snow: number; uv: number; condition: WapiCondition; }; astro: { sunrise: string; sunset: string }; // "07:31 AM", local time hour: WapiHour[]; } interface WapiResponse { location: { tz_id: string; localtime_epoch: number; localtime: string }; current: { last_updated_epoch: number; temp_c: number; feelslike_c: number; humidity: number; wind_kph: number; wind_degree: number; gust_kph: number; pressure_mb: number; dewpoint_c?: number; vis_km: number; uv: number; cloud: number; precip_mm: number; is_day: 0 | 1; condition: WapiCondition; }; forecast: { forecastday: WapiDay[] }; alerts?: { alert: { headline: string; event: string; severity: string; effective: string; expires: string; desc: string; instruction: string }[]; }; } // Condition codes: https://www.weatherapi.com/docs/weather_conditions.json const STORM_CODES = [1087, 1273, 1276, 1279, 1282]; const FOG_CODES = [1030, 1135, 1147]; const CLOUD_CODES = [1003, 1006, 1009]; const SNOW_CODES = [1066, 1069, 1072, 1114, 1117, 1204, 1207, 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1249, 1252, 1255, 1258, 1261, 1264]; function toCondition(code: number): Condition { if (code === 1000) return "clear"; if (STORM_CODES.includes(code)) return "storm"; if (FOG_CODES.includes(code)) return "fog"; if (CLOUD_CODES.includes(code)) return "clouds"; if (SNOW_CODES.includes(code)) return "snow"; return "rain"; // everything left is some flavour of rain or drizzle } /** * Sunrise comes as "07:31 AM" in local time. To turn that into a real instant * we need the location's UTC offset, which we work out from `localtime` * (the local wall-clock time) versus `localtime_epoch` (the true instant). */ function utcOffsetSeconds(location: WapiResponse["location"]): number { const wallClockAsUtc = Date.parse(`${location.localtime.replace(" ", "T")}:00Z`) / 1000; return Math.round((wallClockAsUtc - location.localtime_epoch) / 60) * 60; } function localTimeToIso(date: string, clock: string, offsetSeconds: number): string | null { const match = clock.match(/(\d+):(\d+)\s*(AM|PM)/i); if (!match) return null; // e.g. "No sunrise" near the poles let hours = Number(match[1]) % 12; if (match[3].toUpperCase() === "PM") hours += 12; const wallClockAsUtc = Date.parse(`${date}T00:00:00Z`) + (hours * 60 + Number(match[2])) * 60_000; return new Date(wallClockAsUtc - offsetSeconds * 1000).toISOString(); } export const weatherApi: WeatherProvider = { id: "weatherapi", label: "WeatherAPI", envKey: "WEATHERAPI_API_KEY", // Set WEATHERAPI_PLAN=paid in .env.local if your plan includes 14 days. limits: () => ({ forecastDays: process.env.WEATHERAPI_PLAN === "paid" ? 14 : 3, hourlyStepHours: 1 }), async getForecast(lat, lon) { const key = readKey("WEATHERAPI_API_KEY"); const url = `https://api.weatherapi.com/v1/forecast.json?key=${key}&q=${lat},${lon}&days=${MAX_DAYS}&aqi=no&alerts=yes`; const data = await fetchJson(url, "WeatherAPI"); const now = data.current; const offset = utcOffsetSeconds(data.location); const days = data.forecast.forecastday; const hourly = days.flatMap((day) => day.hour.map((h) => ({ time: fromUnix(h.time_epoch), tempC: round1(h.temp_c), feelsLikeC: round1(h.feelslike_c), condition: toCondition(h.condition.code), isDay: h.is_day === 1, precipChance: Math.max(h.chance_of_rain, h.chance_of_snow), precipMm: maybe(h.precip_mm), windKph: round1(h.wind_kph), gustKph: maybe(h.gust_kph), windDeg: h.wind_degree, humidity: h.humidity, uvIndex: maybe(h.uv), })), ); return { timeZone: data.location.tz_id, hourlyStepHours: 1, current: { time: fromUnix(now.last_updated_epoch), tempC: round1(now.temp_c), feelsLikeC: round1(now.feelslike_c), condition: toCondition(now.condition.code), description: now.condition.text.toLowerCase(), isDay: now.is_day === 1, humidity: now.humidity, windKph: round1(now.wind_kph), gustKph: maybe(now.gust_kph), windDeg: now.wind_degree, pressureHpa: maybe(now.pressure_mb), dewPointC: maybe(now.dewpoint_c), visibilityKm: maybe(now.vis_km), uvIndex: maybe(now.uv), cloudCover: now.cloud, precipMm: maybe(now.precip_mm), }, hourly: nextHours(hourly, MAX_HOURS), daily: days.map((day) => ({ time: `${day.date}T12:00:00.000Z`, // midday UTC lands on the right date almost everywhere highC: round1(day.day.maxtemp_c), lowC: round1(day.day.mintemp_c), condition: toCondition(day.day.condition.code), summary: day.day.condition.text, precipChance: Math.max(day.day.daily_chance_of_rain, day.day.daily_chance_of_snow), precipMm: maybe(day.day.totalprecip_mm), windKph: maybe(day.day.maxwind_kph), gustKph: null, windDeg: null, uvIndexMax: maybe(day.day.uv), humidity: maybe(day.day.avghumidity), sunrise: localTimeToIso(day.date, day.astro.sunrise, offset), sunset: localTimeToIso(day.date, day.astro.sunset, offset), })), alerts: (data.alerts?.alert ?? []).map((a) => ({ title: a.headline || a.event, severity: toSeverity(a.severity), source: "WeatherAPI", starts: a.effective || null, ends: a.expires || null, description: a.desc, instruction: a.instruction || null, })), }; }, };