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}×teps=1h,1d&units=metric&apikey=${key}`; const { timelines } = await fetchJson(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, })), }; }, };