192 lines
6.5 KiB
TypeScript
192 lines
6.5 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",
|
||
|
|
// 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,
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|