This commit is contained in:
Cayden Schwechten 2026-10-31 21:20:16 -05:00
commit 6250d23297
53 changed files with 6381 additions and 0 deletions

View file

@ -0,0 +1,45 @@
import type { ProviderId, ProviderInfo, WeatherProvider } from "../types";
import { openMeteo } from "./openmeteo";
import { openWeather } from "./openweather";
import { tomorrowIo } from "./tomorrow";
import { weatherApi } from "./weatherapi";
/**
* The provider registry.
* To add a provider: write an adapter that satisfies WeatherProvider,
* add it here, and add its id to ProviderId in ../types.ts.
*/
export const PROVIDERS: Record<ProviderId, WeatherProvider> = {
openweather: openWeather,
weatherapi: weatherApi,
tomorrow: tomorrowIo,
openmeteo: openMeteo,
};
/** Order shown in the picker. OpenWeather first because it's the default. */
const DISPLAY_ORDER: ProviderId[] = ["openweather", "weatherapi", "tomorrow", "openmeteo"];
export function isProviderId(value: string): value is ProviderId {
return value in PROVIDERS;
}
/** A provider is ready if it needs no key, or its key is present. */
export function isReady(provider: WeatherProvider): boolean {
return provider.envKey === null || Boolean(process.env[provider.envKey]?.trim());
}
/** Provider list for the browser — readiness and limits only, never the keys.
* Ready sources lead so the picker starts with working options out of the box. */
export function listProviders(): ProviderInfo[] {
const all = DISPLAY_ORDER.map((id) => {
const provider = PROVIDERS[id];
return {
id,
label: provider.label,
ready: isReady(provider),
signupUrl: provider.signupUrl,
...provider.limits(),
};
});
return [...all.filter((p) => p.ready), ...all.filter((p) => !p.ready)];
}

View file

@ -0,0 +1,134 @@
import { CONDITION_LABELS } from "../describe";
import { fetchJson, fromUnix, maybe, MAX_DAYS, MAX_HOURS, nextHours, round1 } from "../http";
import type { Condition, WeatherProvider } from "../types";
/**
* Open-Meteo — free, no key, and the deepest data: hourly and daily out to 16 days.
* Docs: https://open-meteo.com/en/docs
*
* We ask for `timeformat=unixtime` so every time comes back as a plain number.
*/
const HOURLY_VARS = [
"temperature_2m", "apparent_temperature", "precipitation_probability", "precipitation",
"weather_code", "wind_speed_10m", "wind_gusts_10m", "wind_direction_10m",
"relative_humidity_2m", "uv_index", "is_day",
] as const;
const DAILY_VARS = [
"weather_code", "temperature_2m_max", "temperature_2m_min", "precipitation_probability_max",
"precipitation_sum", "wind_speed_10m_max", "wind_gusts_10m_max", "wind_direction_10m_dominant",
"uv_index_max", "sunrise", "sunset",
] as const;
const CURRENT_VARS = [
"temperature_2m", "apparent_temperature", "relative_humidity_2m", "is_day", "precipitation",
"weather_code", "cloud_cover", "pressure_msl", "wind_speed_10m", "wind_direction_10m",
"wind_gusts_10m", "dew_point_2m", "visibility", "uv_index",
] as const;
// Each section is "variable name → value" (current) or "variable name → array of values".
type Row<Names extends readonly string[]> = Record<Names[number], number>;
type Columns<Names extends readonly string[]> = Record<Names[number], (number | null)[]> & { time: number[] };
interface OpenMeteoResponse {
timezone: string;
current: Row<typeof CURRENT_VARS> & { time: number };
hourly: Columns<typeof HOURLY_VARS>;
daily: Columns<typeof DAILY_VARS>;
}
/** WMO weather codes, listed at the bottom of the Open-Meteo docs page. */
function toCondition(code: number | null): Condition {
if (code === null || code <= 1) return "clear";
if (code <= 3) return "clouds";
if (code === 45 || code === 48) return "fog";
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return "snow";
if (code >= 95) return "storm";
return "rain"; // drizzle (51–57), rain (61–67), showers (80–82)
}
const TWELVE_HOURS = 12 * 60 * 60;
export const openMeteo: WeatherProvider = {
id: "openmeteo",
label: "Open-Meteo",
envKey: null,
signupUrl: "https://open-meteo.com/",
limits: () => ({ forecastDays: 14, hourlyStepHours: 1 }),
async getForecast(lat, lon) {
const params = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
current: CURRENT_VARS.join(","),
hourly: HOURLY_VARS.join(","),
daily: DAILY_VARS.join(","),
timezone: "auto",
timeformat: "unixtime",
forecast_days: String(MAX_DAYS),
});
const { current: c, hourly: h, daily: d, timezone } = await fetchJson<OpenMeteoResponse>(
`https://api.open-meteo.com/v1/forecast?${params}`,
"Open-Meteo",
);
const condition = toCondition(c.weather_code);
const hourly = h.time.map((t, i) => ({
time: fromUnix(t),
tempC: round1(h.temperature_2m[i] ?? 0),
feelsLikeC: maybe(h.apparent_temperature[i]),
condition: toCondition(h.weather_code[i]),
isDay: h.is_day[i] === 1,
precipChance: maybe(h.precipitation_probability[i]),
precipMm: maybe(h.precipitation[i]),
windKph: round1(h.wind_speed_10m[i] ?? 0),
gustKph: maybe(h.wind_gusts_10m[i]),
windDeg: maybe(h.wind_direction_10m[i]),
humidity: maybe(h.relative_humidity_2m[i]),
uvIndex: maybe(h.uv_index[i]),
}));
return {
timeZone: timezone,
hourlyStepHours: 1,
alerts: [], // Open-Meteo has no alerts; the API route adds NWS alerts for US locations
current: {
time: fromUnix(c.time),
tempC: round1(c.temperature_2m),
feelsLikeC: round1(c.apparent_temperature),
condition,
description: CONDITION_LABELS[condition],
isDay: c.is_day === 1,
humidity: c.relative_humidity_2m,
windKph: round1(c.wind_speed_10m),
gustKph: maybe(c.wind_gusts_10m),
windDeg: maybe(c.wind_direction_10m),
pressureHpa: maybe(c.pressure_msl),
dewPointC: maybe(c.dew_point_2m),
visibilityKm: c.visibility == null ? null : round1(c.visibility / 1000),
uvIndex: maybe(c.uv_index),
cloudCover: maybe(c.cloud_cover),
precipMm: maybe(c.precipitation),
},
hourly: nextHours(hourly, MAX_HOURS),
daily: d.time.map((t, i) => ({
time: fromUnix(t + TWELVE_HOURS), // daily times are local midnight; use midday
highC: round1(d.temperature_2m_max[i] ?? 0),
lowC: round1(d.temperature_2m_min[i] ?? 0),
condition: toCondition(d.weather_code[i]),
summary: null,
precipChance: maybe(d.precipitation_probability_max[i]),
precipMm: maybe(d.precipitation_sum[i]),
windKph: maybe(d.wind_speed_10m_max[i]),
gustKph: maybe(d.wind_gusts_10m_max[i]),
windDeg: maybe(d.wind_direction_10m_dominant[i]),
uvIndexMax: maybe(d.uv_index_max[i]),
humidity: null,
sunrise: d.sunrise[i] ? fromUnix(d.sunrise[i]!) : null,
sunset: d.sunset[i] ? fromUnix(d.sunset[i]!) : null,
})),
};
},
};

View file

@ -0,0 +1,134 @@
import { fetchJson, fromUnix, maybe, maybeMsToKph, msToKph, round1 } from "../../http";
import type { DailyForecast, HourlyForecast, ProviderForecast } from "../../types";
import { OWM_LABEL, popToPercent, precipitation, toCondition, type OwmWeatherTag } from "./shared";
/**
* OpenWeather FREE plan:
* /data/2.5/weather → current conditions
* /data/2.5/forecast → 5 days in 3-hour steps
* There is no daily endpoint, so we build days by grouping the 3-hour steps.
*/
const BASE_URL = "https://api.openweathermap.org/data/2.5";
interface OwmCurrentResponse {
dt: number;
timezone: number; // seconds from UTC
weather: OwmWeatherTag[];
main: { temp: number; feels_like: number; humidity: number; pressure: number };
visibility?: number; // metres
wind: { speed: number; deg?: number; gust?: number }; // m/s
clouds?: { all: number };
rain?: { "1h"?: number };
snow?: { "1h"?: number };
sys: { sunrise: number; sunset: number };
}
interface OwmForecastEntry {
dt: number;
main: { temp: number; feels_like: number; temp_min: number; temp_max: number; humidity: number };
weather: OwmWeatherTag[];
wind: { speed: number; deg?: number; gust?: number };
pop?: number;
rain?: { "3h"?: number };
snow?: { "3h"?: number };
sys: { pod: "d" | "n" };
}
interface OwmForecastResponse {
list: OwmForecastEntry[];
city: { timezone: number };
}
function toHourly(e: OwmForecastEntry): HourlyForecast {
return {
time: fromUnix(e.dt),
tempC: round1(e.main.temp),
feelsLikeC: round1(e.main.feels_like),
condition: toCondition(e.weather[0].id),
isDay: e.sys.pod === "d",
precipChance: popToPercent(e.pop),
precipMm: precipitation(e.rain?.["3h"], e.snow?.["3h"]),
windKph: msToKph(e.wind.speed),
gustKph: maybeMsToKph(e.wind.gust),
windDeg: maybe(e.wind.deg),
humidity: e.main.humidity,
uvIndex: null, // not on the free plan
};
}
/** Groups the 3-hour steps by local calendar day and summarises each day. */
function buildDaily(response: OwmForecastResponse, today: OwmCurrentResponse): DailyForecast[] {
const offset = response.city.timezone;
const localDay = (e: OwmForecastEntry) => new Date((e.dt + offset) * 1000).toISOString().slice(0, 10);
const localHour = (e: OwmForecastEntry) => new Date((e.dt + offset) * 1000).getUTCHours();
const entriesByDay = new Map<string, OwmForecastEntry[]>();
for (const entry of response.list) {
const day = localDay(entry);
entriesByDay.set(day, [...(entriesByDay.get(day) ?? []), entry]);
}
return [...entriesByDay.values()].map((entries, index) => {
// The step nearest midday best represents "what the day looks like".
const midday = entries.reduce((best, e) => (Math.abs(localHour(e) - 12) < Math.abs(localHour(best) - 12) ? e : best));
const pops = entries.map((e) => popToPercent(e.pop) ?? 0);
const gusts = entries.map((e) => e.wind.gust ?? 0);
return {
time: fromUnix(midday.dt),
highC: round1(Math.max(...entries.map((e) => e.main.temp_max))),
lowC: round1(Math.min(...entries.map((e) => e.main.temp_min))),
condition: toCondition(midday.weather[0].id),
summary: midday.weather[0].description,
precipChance: Math.max(...pops),
precipMm: round1(entries.reduce((sum, e) => sum + (precipitation(e.rain?.["3h"], e.snow?.["3h"]) ?? 0), 0)),
windKph: msToKph(Math.max(...entries.map((e) => e.wind.speed))),
gustKph: Math.max(...gusts) > 0 ? msToKph(Math.max(...gusts)) : null,
windDeg: maybe(midday.wind.deg),
uvIndexMax: null,
humidity: Math.round(entries.reduce((sum, e) => sum + e.main.humidity, 0) / entries.length),
// Only today's sun times are available on this plan.
sunrise: index === 0 ? fromUnix(today.sys.sunrise) : null,
sunset: index === 0 ? fromUnix(today.sys.sunset) : null,
};
});
}
export async function getFreeForecast(lat: number, lon: number, key: string): Promise<ProviderForecast> {
const query = `lat=${lat}&lon=${lon}&units=metric&appid=${key}`;
// Both requests are independent, so run them at the same time.
const [now, forecast] = await Promise.all([
fetchJson<OwmCurrentResponse>(`${BASE_URL}/weather?${query}`, OWM_LABEL),
fetchJson<OwmForecastResponse>(`${BASE_URL}/forecast?${query}`, OWM_LABEL),
]);
const tag = now.weather[0];
return {
timeZone: null, // only a UTC offset is given; the API route fills in the name
hourlyStepHours: 3,
alerts: [],
current: {
time: fromUnix(now.dt),
tempC: round1(now.main.temp),
feelsLikeC: round1(now.main.feels_like),
condition: toCondition(tag.id),
description: tag.description,
isDay: tag.icon.endsWith("d"),
humidity: now.main.humidity,
windKph: msToKph(now.wind.speed),
gustKph: maybeMsToKph(now.wind.gust),
windDeg: maybe(now.wind.deg),
pressureHpa: now.main.pressure,
dewPointC: null,
visibilityKm: now.visibility === undefined ? null : round1(now.visibility / 1000),
uvIndex: null,
cloudCover: maybe(now.clouds?.all),
precipMm: precipitation(now.rain?.["1h"], now.snow?.["1h"]),
},
hourly: forecast.list.map(toHourly),
daily: buildDaily(forecast, now),
};
}

View file

@ -0,0 +1,26 @@
import { readKey } from "../../http";
import type { WeatherProvider } from "../../types";
import { getFreeForecast } from "./free";
import { getOneCallForecast } from "./onecall";
/**
* OpenWeather (the default provider).
*
* Set OPENWEATHER_PLAN in .env.local:
* free (default) → 5 days in 3-hour steps
* onecall → One Call 4.0: 48 hours hourly + daily, plus dew point and UV
*/
const usesOneCall = () => process.env.OPENWEATHER_PLAN === "onecall";
export const openWeather: WeatherProvider = {
id: "openweather",
label: "OpenWeather",
envKey: "OPENWEATHER_API_KEY",
signupUrl: "https://home.openweathermap.org/users/sign_up",
limits: () => (usesOneCall() ? { forecastDays: 8, hourlyStepHours: 1 } : { forecastDays: 5, hourlyStepHours: 3 }),
getForecast(lat, lon) {
const key = readKey("OPENWEATHER_API_KEY");
return usesOneCall() ? getOneCallForecast(lat, lon, key) : getFreeForecast(lat, lon, key);
},
};

View file

@ -0,0 +1,146 @@
import { fetchJson, fromUnix, maybe, maybeMsToKph, MAX_DAYS, msToKph, ProviderError, round1 } from "../../http";
import type { CurrentWeather, DailyForecast, HourlyForecast, ProviderForecast } from "../../types";
import { OWM_LABEL, popToPercent, precipitation, toCondition, type OwmWeatherTag } from "./shared";
/**
* OpenWeather ONE CALL 4.0 (needs a separate "One Call" subscription;
* 1,000 calls/day free, card on file). Docs: https://openweathermap.org/api/one-call-4
*
* /data/4.0/onecall/current → now
* /data/4.0/onecall/timeline/1h → hourly, 48 h of forecast, 20 records per page
* /data/4.0/onecall/timeline/1day → daily, 10 records per page
*
* Timelines are paginated with a `next` URL, and each page costs one call.
* A full load here is about 5 calls; the 10-minute cache keeps that sane.
*/
const BASE_URL = "https://api.openweathermap.org/data/4.0/onecall";
const HOURS_AVAILABLE = 48;
interface OcRecord {
dt: number;
temp: number | { min: number; max: number; day: number };
feels_like: number | { day: number };
humidity: number;
pressure: number;
dew_point: number;
uvi: number;
clouds: number;
visibility?: number; // metres
wind_speed: number;
wind_gust?: number;
wind_deg: number;
pop?: number;
rain?: number | { "1h": number };
snow?: number | { "1h": number };
weather: OwmWeatherTag[];
sunrise?: number;
sunset?: number;
summary?: string;
}
interface OcPage {
data: OcRecord[];
next?: string;
timezone?: string;
}
/** Rain/snow is a number on daily records and `{ "1h": n }` on current/hourly ones. */
const volume = (v: OcRecord["rain"]) => (typeof v === "number" ? v : v?.["1h"]);
/** Follows `next` links until we have `wanted` records or the timeline ends. */
async function fetchTimeline(firstUrl: string, key: string, wanted: number): Promise<OcRecord[]> {
const records: OcRecord[] = [];
let url: string | undefined = firstUrl;
while (url && records.length < wanted) {
const page: OcPage = await fetchJson<OcPage>(url, OWM_LABEL);
records.push(...page.data);
// Make sure follow-up pages carry the key even if `next` omits it.
url = page.next ? (page.next.includes("appid=") ? page.next : `${page.next}&appid=${key}`) : undefined;
}
return records.slice(0, wanted);
}
function toHourly(r: OcRecord): HourlyForecast {
return {
time: fromUnix(r.dt),
tempC: round1(r.temp as number),
feelsLikeC: round1(r.feels_like as number),
condition: toCondition(r.weather[0].id),
isDay: r.weather[0].icon.endsWith("d"),
precipChance: popToPercent(r.pop),
precipMm: precipitation(volume(r.rain), volume(r.snow)),
windKph: msToKph(r.wind_speed),
gustKph: maybeMsToKph(r.wind_gust),
windDeg: r.wind_deg,
humidity: r.humidity,
uvIndex: maybe(r.uvi),
};
}
function toDaily(r: OcRecord): DailyForecast {
const temp = r.temp as { min: number; max: number };
return {
time: fromUnix(r.dt), // OpenWeather stamps daily records at local midday
highC: round1(temp.max),
lowC: round1(temp.min),
condition: toCondition(r.weather[0].id),
summary: r.summary ?? r.weather[0].description,
precipChance: popToPercent(r.pop),
precipMm: precipitation(volume(r.rain), volume(r.snow)),
windKph: msToKph(r.wind_speed),
gustKph: maybeMsToKph(r.wind_gust),
windDeg: r.wind_deg,
uvIndexMax: maybe(r.uvi),
humidity: r.humidity,
sunrise: r.sunrise ? fromUnix(r.sunrise) : null,
sunset: r.sunset ? fromUnix(r.sunset) : null,
};
}
function toCurrent(r: OcRecord): CurrentWeather {
const hour = toHourly(r);
return {
time: hour.time,
tempC: hour.tempC,
feelsLikeC: hour.feelsLikeC ?? hour.tempC,
condition: hour.condition,
description: r.weather[0].description,
isDay: hour.isDay,
humidity: r.humidity,
windKph: hour.windKph,
gustKph: hour.gustKph,
windDeg: hour.windDeg,
pressureHpa: r.pressure,
dewPointC: round1(r.dew_point),
visibilityKm: r.visibility === undefined ? null : round1(r.visibility / 1000),
uvIndex: maybe(r.uvi),
cloudCover: r.clouds,
precipMm: hour.precipMm,
};
}
export async function getOneCallForecast(lat: number, lon: number, key: string): Promise<ProviderForecast> {
const query = `lat=${lat}&lon=${lon}&units=metric&appid=${key}`;
const nowSeconds = Math.floor(Date.now() / 1000);
const startOfHour = nowSeconds - (nowSeconds % 3600);
const [currentPage, hourly, daily] = await Promise.all([
fetchJson<OcPage>(`${BASE_URL}/current?${query}`, OWM_LABEL),
fetchTimeline(`${BASE_URL}/timeline/1h?${query}&start=${startOfHour}`, key, HOURS_AVAILABLE),
fetchTimeline(`${BASE_URL}/timeline/1day?${query}&start=${nowSeconds}`, key, MAX_DAYS),
]);
const currentRecord = currentPage.data[0];
if (!currentRecord) throw new ProviderError("OpenWeather returned no current reading", 502);
return {
timeZone: currentPage.timezone ?? null,
hourlyStepHours: 1,
alerts: [], // 4.0 only returns alert IDs; the API route adds NWS alerts for US locations
current: toCurrent(currentRecord),
hourly: hourly.map(toHourly),
daily: daily.map(toDaily),
};
}

View file

@ -0,0 +1,32 @@
import type { Condition } from "../../types";
/** Shared by both OpenWeather plans. */
export const OWM_LABEL = "OpenWeather";
export interface OwmWeatherTag {
id: number;
description: string;
icon: string; // ends in "d" (day) or "n" (night)
}
/** Condition codes: https://openweathermap.org/weather-conditions */
export function toCondition(code: number): Condition {
if (code >= 200 && code < 300) return "storm";
if (code >= 300 && code < 600) return "rain"; // drizzle + rain
if (code >= 600 && code < 700) return "snow";
if (code >= 700 && code < 800) return "fog"; // mist, haze, smoke, dust…
if (code === 800) return "clear";
return "clouds";
}
/** OpenWeather gives probability of precipitation as 0–1; we use 0–100. */
export function popToPercent(pop: number | undefined): number | null {
return pop === undefined ? null : Math.round(pop * 100);
}
/** Rain + snow volume, either of which may be missing. */
export function precipitation(rain?: number, snow?: number): number | null {
if (rain === undefined && snow === undefined) return null;
return Math.round(((rain ?? 0) + (snow ?? 0)) * 10) / 10;
}

View file

@ -0,0 +1,138 @@
import { CONDITION_LABELS } from "../describe";
import { fetchJson, maybe, maybeMsToKph, MAX_HOURS, msToKph, nextHours, ProviderError, readKey, round1 } from "../http";
import type { Condition, WeatherProvider } from "../types";
/**
* Tomorrow.io — one forecast request with hourly (≈120 h) and daily (≈6 days) timelines.
* "Current" is the first hourly entry, which Tomorrow.io sets to the present hour.
* Free plan: 25 requests/hour, so the 10-minute cache matters here.
* Docs: https://docs.tomorrow.io/reference/weather-forecast
*/
interface TmrwHourlyValues {
temperature: number;
temperatureApparent: number;
humidity: number;
windSpeed: number; // m/s with units=metric
windGust?: number;
windDirection?: number;
weatherCode: number;
precipitationProbability?: number;
rainIntensity?: number; // mm/h
snowIntensity?: number;
uvIndex?: number;
pressureSurfaceLevel?: number;
dewPoint?: number;
visibility?: number; // km
cloudCover?: number;
}
interface TmrwDailyValues {
temperatureMax: number;
temperatureMin: number;
weatherCodeMax: number;
precipitationProbabilityMax?: number;
rainAccumulationSum?: number;
snowAccumulationSum?: number;
windSpeedMax?: number;
windGustMax?: number;
windDirectionAvg?: number;
uvIndexMax?: number;
humidityAvg?: number;
sunriseTime?: string;
sunsetTime?: string;
}
interface TmrwResponse {
timelines: {
hourly: { time: string; values: TmrwHourlyValues }[];
daily: { time: string; values: TmrwDailyValues }[];
};
}
/** Weather codes: https://docs.tomorrow.io/reference/data-layers-weather-codes */
function toCondition(code: number): Condition {
if (code === 1000 || code === 1100) return "clear";
if (code === 8000) return "storm";
if (code >= 2000 && code < 3000) return "fog";
if (code >= 4000 && code < 5000) return "rain";
if (code >= 5000 && code < 6000) return "snow";
if (code >= 6000 && code < 7000) return "rain"; // freezing rain / drizzle
if (code >= 7000 && code < 8000) return "snow"; // ice pellets
return "clouds";
}
/** Daytime if the instant falls between sunrise and sunset of a day that has them. */
function isDaytime(instant: string, days: TmrwResponse["timelines"]["daily"]): boolean {
const t = Date.parse(instant);
return days.some(({ values: v }) =>
v.sunriseTime && v.sunsetTime ? t >= Date.parse(v.sunriseTime) && t < Date.parse(v.sunsetTime) : false,
);
}
export const tomorrowIo: WeatherProvider = {
id: "tomorrow",
label: "Tomorrow.io",
envKey: "TOMORROW_API_KEY",
signupUrl: "https://app.tomorrow.io/signup",
limits: () => ({ forecastDays: 6, hourlyStepHours: 1 }),
async getForecast(lat, lon) {
const key = readKey("TOMORROW_API_KEY");
const url = `https://api.tomorrow.io/v4/weather/forecast?location=${lat},${lon}&timesteps=1h,1d&units=metric&apikey=${key}`;
const { timelines } = await fetchJson<TmrwResponse>(url, "Tomorrow.io");
const hourly = timelines.hourly.map(({ time, values: v }) => ({
time: new Date(time).toISOString(),
tempC: round1(v.temperature),
feelsLikeC: round1(v.temperatureApparent),
condition: toCondition(v.weatherCode),
isDay: isDaytime(time, timelines.daily),
precipChance: maybe(v.precipitationProbability),
precipMm: maybe((v.rainIntensity ?? 0) + (v.snowIntensity ?? 0)),
windKph: msToKph(v.windSpeed),
gustKph: maybeMsToKph(v.windGust),
windDeg: maybe(v.windDirection),
humidity: maybe(v.humidity),
uvIndex: maybe(v.uvIndex),
}));
const now = timelines.hourly[0];
if (!now) throw new ProviderError("Tomorrow.io returned an empty forecast", 502);
const condition = toCondition(now.values.weatherCode);
return {
timeZone: null, // Tomorrow.io doesn't say; the API route fills it in
hourlyStepHours: 1,
alerts: [],
current: {
...hourly[0],
feelsLikeC: hourly[0].feelsLikeC ?? hourly[0].tempC,
humidity: Math.round(now.values.humidity),
description: CONDITION_LABELS[condition],
pressureHpa: maybe(now.values.pressureSurfaceLevel),
dewPointC: maybe(now.values.dewPoint),
visibilityKm: maybe(now.values.visibility),
cloudCover: maybe(now.values.cloudCover),
precipMm: hourly[0].precipMm,
},
hourly: nextHours(hourly, MAX_HOURS),
daily: timelines.daily.map(({ time, values: v }) => ({
time: new Date(time).toISOString(), // Tomorrow.io starts each day at 6 am local
highC: round1(v.temperatureMax),
lowC: round1(v.temperatureMin),
condition: toCondition(v.weatherCodeMax),
summary: null,
precipChance: maybe(v.precipitationProbabilityMax),
precipMm: maybe((v.rainAccumulationSum ?? 0) + (v.snowAccumulationSum ?? 0)),
windKph: maybeMsToKph(v.windSpeedMax),
gustKph: maybeMsToKph(v.windGustMax),
windDeg: maybe(v.windDirectionAvg),
uvIndexMax: maybe(v.uvIndexMax),
humidity: maybe(v.humidityAvg),
sunrise: v.sunriseTime ?? null,
sunset: v.sunsetTime ?? null,
})),
};
},
};

View file

@ -0,0 +1,192 @@
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,
})),
};
},
};