wayfarer-weather/lib/weather/providers/tomorrow.ts

138 lines
5 KiB
TypeScript
Raw Normal View History

2026-10-31 21:20:16 -05:00
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",
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,
})),
};
},
};