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(); 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 { 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(`${BASE_URL}/weather?${query}`, OWM_LABEL), fetchJson(`${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), }; }