wayfarer-weather/lib/weather/providers/weatherapi.ts
Cayden Schwechten 6250d23297 init
2026-10-31 21:20:16 -05:00

192 lines
6.6 KiB
TypeScript

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",
signupUrl: "https://www.weatherapi.com/signup.aspx",
// 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<WapiResponse>(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,
})),
};
},
};