init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
18
lib/weather/describe.ts
Normal file
18
lib/weather/describe.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { AlertSeverity, Condition } from "./types";
|
||||
|
||||
/** Fallback wording for providers that only give us a numeric code. */
|
||||
export const CONDITION_LABELS: Record<Condition, string> = {
|
||||
clear: "clear skies",
|
||||
clouds: "cloudy",
|
||||
rain: "rain",
|
||||
storm: "thunderstorm",
|
||||
snow: "snow",
|
||||
fog: "fog",
|
||||
};
|
||||
|
||||
/** Maps any provider's severity word ("Severe", "severe", "Moderate"…) onto ours. */
|
||||
export function toSeverity(word: string | null | undefined): AlertSeverity {
|
||||
const normalized = (word ?? "").toLowerCase();
|
||||
const known: AlertSeverity[] = ["extreme", "severe", "moderate", "minor"];
|
||||
return known.find((s) => normalized.includes(s)) ?? "unknown";
|
||||
}
|
||||
50
lib/weather/extras/air-quality.ts
Normal file
50
lib/weather/extras/air-quality.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { fetchJson, maybe } from "../http";
|
||||
import type { AirQuality } from "../types";
|
||||
|
||||
/**
|
||||
* Air quality from Open-Meteo's free air-quality API (CAMS data), used for
|
||||
* every provider so the AQI always means the same thing.
|
||||
* Docs: https://open-meteo.com/en/docs/air-quality-api
|
||||
*
|
||||
* The same call also tells us the location's IANA time zone, which some
|
||||
* providers (Tomorrow.io, OpenWeather free) don't give us.
|
||||
*/
|
||||
|
||||
interface AirQualityResponse {
|
||||
timezone: string;
|
||||
current: {
|
||||
us_aqi: number | null;
|
||||
pm2_5: number | null;
|
||||
pm10: number | null;
|
||||
ozone: number | null;
|
||||
nitrogen_dioxide: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalContext {
|
||||
timeZone: string;
|
||||
airQuality: AirQuality | null;
|
||||
}
|
||||
|
||||
export async function getLocalContext(lat: number, lon: number): Promise<LocalContext> {
|
||||
const params = new URLSearchParams({
|
||||
latitude: String(lat),
|
||||
longitude: String(lon),
|
||||
current: "us_aqi,pm2_5,pm10,ozone,nitrogen_dioxide",
|
||||
timezone: "auto",
|
||||
});
|
||||
const data = await fetchJson<AirQualityResponse>(
|
||||
`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`,
|
||||
"Open-Meteo air quality",
|
||||
{ cacheSeconds: 1800 }, // air quality updates hourly at most
|
||||
);
|
||||
|
||||
const c = data.current;
|
||||
return {
|
||||
timeZone: data.timezone,
|
||||
airQuality:
|
||||
c.us_aqi === null
|
||||
? null
|
||||
: { usAqi: Math.round(c.us_aqi), pm25: maybe(c.pm2_5), pm10: maybe(c.pm10), ozone: maybe(c.ozone), no2: maybe(c.nitrogen_dioxide) },
|
||||
};
|
||||
}
|
||||
75
lib/weather/extras/nws-alerts.ts
Normal file
75
lib/weather/extras/nws-alerts.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { toSeverity } from "../describe";
|
||||
import { fetchJson } from "../http";
|
||||
import type { WeatherAlert } from "../types";
|
||||
|
||||
/**
|
||||
* Active US weather alerts from the National Weather Service (free, no key).
|
||||
* Docs: https://www.weather.gov/documentation/services-web-api
|
||||
*
|
||||
* NWS asks every app to identify itself with a User-Agent containing contact
|
||||
* info, so set NWS_CONTACT in .env.local (an email or website).
|
||||
* Outside the US this returns nothing, which is fine.
|
||||
*/
|
||||
|
||||
interface NwsResponse {
|
||||
features: {
|
||||
properties: {
|
||||
event: string;
|
||||
headline: string | null;
|
||||
severity: string;
|
||||
senderName: string;
|
||||
onset: string | null;
|
||||
effective: string;
|
||||
ends: string | null;
|
||||
expires: string;
|
||||
description: string;
|
||||
instruction: string | null;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Rough boxes around US states and territories — skips pointless calls elsewhere. */
|
||||
function mightBeInUS(lat: number, lon: number): boolean {
|
||||
const mainland = lat > 24 && lat < 50 && lon > -125 && lon < -66;
|
||||
const alaska = lat > 51 && lat < 72 && (lon < -129 || lon > 172);
|
||||
const hawaii = lat > 18 && lat < 23 && lon > -161 && lon < -154;
|
||||
const caribbean = lat > 17 && lat < 19 && lon > -68 && lon < -64; // Puerto Rico, USVI
|
||||
const pacific = lat > 13 && lat < 21 && lon > 144 && lon < 146; // Guam, Northern Marianas
|
||||
return mainland || alaska || hawaii || caribbean || pacific;
|
||||
}
|
||||
|
||||
let warnedMissingContact = false;
|
||||
|
||||
function buildNwsUserAgent(): string {
|
||||
const explicit = process.env.NWS_CONTACT?.trim();
|
||||
if (explicit) return `WayfarerWeather/1.0 (${explicit})`;
|
||||
|
||||
const deployedHost = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim() || process.env.VERCEL_URL?.trim();
|
||||
if (deployedHost) return `WayfarerWeather/1.0 (https://${deployedHost})`;
|
||||
|
||||
if (process.env.NODE_ENV === "production" && !warnedMissingContact) {
|
||||
warnedMissingContact = true;
|
||||
console.warn("[Wayfarer] Set NWS_CONTACT (email or site URL) so api.weather.gov requests include contact details.");
|
||||
}
|
||||
return "WayfarerWeather/1.0 (local-dev)";
|
||||
}
|
||||
|
||||
export async function getNwsAlerts(lat: number, lon: number): Promise<WeatherAlert[]> {
|
||||
if (!mightBeInUS(lat, lon)) return [];
|
||||
|
||||
const point = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||
const data = await fetchJson<NwsResponse>(`https://api.weather.gov/alerts/active?point=${point}`, "National Weather Service", {
|
||||
cacheSeconds: 300, // alerts matter; refresh more often
|
||||
headers: { "User-Agent": buildNwsUserAgent(), Accept: "application/geo+json" },
|
||||
});
|
||||
|
||||
return data.features.map(({ properties: p }) => ({
|
||||
title: p.headline ?? p.event,
|
||||
severity: toSeverity(p.severity),
|
||||
source: p.senderName,
|
||||
starts: p.onset ?? p.effective,
|
||||
ends: p.ends ?? p.expires,
|
||||
description: p.description,
|
||||
instruction: p.instruction,
|
||||
}));
|
||||
}
|
||||
60
lib/weather/geocode.ts
Normal file
60
lib/weather/geocode.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { fetchJson } from "./http";
|
||||
import type { Place } from "./types";
|
||||
|
||||
/**
|
||||
* Turns a city name into coordinates (and coordinates back into a name).
|
||||
*
|
||||
* OpenWeather's geocoder is used when its key is set; otherwise we fall back
|
||||
* to Open-Meteo's free geocoder so search still works with zero keys.
|
||||
*/
|
||||
|
||||
interface OwmPlace {
|
||||
name: string;
|
||||
state?: string;
|
||||
country: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
interface OpenMeteoGeocodeResponse {
|
||||
results?: { name: string; admin1?: string; country_code: string; latitude: number; longitude: number }[];
|
||||
}
|
||||
|
||||
const OWM_GEO = "https://api.openweathermap.org/geo/1.0";
|
||||
|
||||
function fromOwm(place: OwmPlace): Place {
|
||||
return { name: place.name, region: place.state, country: place.country, lat: place.lat, lon: place.lon };
|
||||
}
|
||||
|
||||
export async function searchPlaces(query: string): Promise<Place[]> {
|
||||
const key = process.env.OPENWEATHER_API_KEY?.trim();
|
||||
const q = encodeURIComponent(query.slice(0, 120));
|
||||
|
||||
if (key) {
|
||||
const places = await fetchJson<OwmPlace[]>(`${OWM_GEO}/direct?q=${q}&limit=5&appid=${key}`, "OpenWeather");
|
||||
return places.map(fromOwm);
|
||||
}
|
||||
|
||||
const data = await fetchJson<OpenMeteoGeocodeResponse>(
|
||||
`https://geocoding-api.open-meteo.com/v1/search?name=${q}&count=5`,
|
||||
"Open-Meteo",
|
||||
);
|
||||
return (data.results ?? []).map((r) => ({
|
||||
name: r.name,
|
||||
region: r.admin1,
|
||||
country: r.country_code,
|
||||
lat: r.latitude,
|
||||
lon: r.longitude,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Used by "Use my location". Without an OpenWeather key we can't name the spot. */
|
||||
export async function reversePlace(lat: number, lon: number): Promise<Place> {
|
||||
const fallback: Place = { name: "Your location", lat, lon };
|
||||
const key = process.env.OPENWEATHER_API_KEY?.trim();
|
||||
if (!key) return fallback;
|
||||
|
||||
const places = await fetchJson<OwmPlace[]>(`${OWM_GEO}/reverse?lat=${lat}&lon=${lon}&limit=1&appid=${key}`, "OpenWeather");
|
||||
// Keep the user's exact coordinates rather than the city centre's.
|
||||
return places[0] ? { ...fromOwm(places[0]), lat, lon } : fallback;
|
||||
}
|
||||
93
lib/weather/http.ts
Normal file
93
lib/weather/http.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Small server-side helpers shared by every provider adapter.
|
||||
*/
|
||||
|
||||
/** An error that knows which HTTP status the API route should answer with. */
|
||||
export class ProviderError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number = 502,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProviderError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads an API key from the environment, or fails with a clear message. */
|
||||
export function readKey(envKey: string): string {
|
||||
const key = process.env[envKey]?.trim();
|
||||
if (!key) {
|
||||
throw new ProviderError(`${envKey} is not configured`, 500);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
/** Seconds to cache the response. Weather barely changes in 10 minutes. */
|
||||
cacheSeconds?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches JSON with caching (default 10 minutes) to protect free-tier quotas.
|
||||
* The URL often contains an API key, so it is never logged or echoed back.
|
||||
*/
|
||||
export async function fetchJson<T>(url: string, providerLabel: string, options: FetchOptions = {}): Promise<T> {
|
||||
const { cacheSeconds = 600, headers } = options;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { headers, next: { revalidate: cacheSeconds } });
|
||||
} catch {
|
||||
// DNS failure, offline, firewall… the request never got an answer.
|
||||
throw new ProviderError(`Couldn't reach ${providerLabel}. Check your connection and try again.`, 503);
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new ProviderError(`${providerLabel} rejected the API key`, 401);
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new ProviderError(`${providerLabel} rate limit reached. Try again shortly.`, 429);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ProviderError(`${providerLabel} responded with HTTP ${response.status}`, 502);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
// ---------- Number and time helpers ----------
|
||||
|
||||
/** Rounds to one decimal place so the JSON stays tidy. */
|
||||
export function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/** Same as round1, but passes null/undefined/NaN through as null. */
|
||||
export function maybe(value: number | null | undefined): number | null {
|
||||
return value === null || value === undefined || !Number.isFinite(value) ? null : round1(value);
|
||||
}
|
||||
|
||||
/** Metres per second → km/h. */
|
||||
export function msToKph(ms: number): number {
|
||||
return round1(ms * 3.6);
|
||||
}
|
||||
|
||||
export function maybeMsToKph(ms: number | null | undefined): number | null {
|
||||
return ms === null || ms === undefined ? null : msToKph(ms);
|
||||
}
|
||||
|
||||
/** Unix seconds → ISO instant. */
|
||||
export function fromUnix(seconds: number): string {
|
||||
return new Date(seconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
/** Keeps the first `hours` of an hourly list, starting from the current hour. */
|
||||
export function nextHours<T extends { time: string }>(items: T[], hours: number): T[] {
|
||||
const startOfThisHour = Date.now() - 60 * 60 * 1000;
|
||||
return items.filter((item) => Date.parse(item.time) >= startOfThisHour).slice(0, hours);
|
||||
}
|
||||
|
||||
/** The app shows at most 7 days of hourly data and 14 days of daily data. */
|
||||
export const MAX_HOURS = 168;
|
||||
export const MAX_DAYS = 14;
|
||||
45
lib/weather/providers/index.ts
Normal file
45
lib/weather/providers/index.ts
Normal 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)];
|
||||
}
|
||||
134
lib/weather/providers/openmeteo.ts
Normal file
134
lib/weather/providers/openmeteo.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
134
lib/weather/providers/openweather/free.ts
Normal file
134
lib/weather/providers/openweather/free.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
26
lib/weather/providers/openweather/index.ts
Normal file
26
lib/weather/providers/openweather/index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
146
lib/weather/providers/openweather/onecall.ts
Normal file
146
lib/weather/providers/openweather/onecall.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
32
lib/weather/providers/openweather/shared.ts
Normal file
32
lib/weather/providers/openweather/shared.ts
Normal 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;
|
||||
}
|
||||
138
lib/weather/providers/tomorrow.ts
Normal file
138
lib/weather/providers/tomorrow.ts
Normal 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}×teps=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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
192
lib/weather/providers/weatherapi.ts
Normal file
192
lib/weather/providers/weatherapi.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
35
lib/weather/respond.ts
Normal file
35
lib/weather/respond.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { ProviderError } from "./http";
|
||||
|
||||
/** Turns any thrown error into a JSON `{ error }` response with a sensible status. */
|
||||
export function errorResponse(error: unknown): NextResponse {
|
||||
if (error instanceof ProviderError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
console.error(error);
|
||||
return NextResponse.json({ error: "Something went wrong reaching the weather service" }, { status: 500 });
|
||||
}
|
||||
|
||||
export function badRequest(message: string): NextResponse {
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
/** Rejects cross-site browser requests so external pages can't hotlink your API/tile quotas. */
|
||||
export function forbidCrossSite(request: NextRequest): NextResponse | null {
|
||||
if (request.headers.get("sec-fetch-site") === "cross-site") {
|
||||
return NextResponse.json({ error: "Cross-site requests are not permitted" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parses a lat/lon pair from query params, or returns null if either is missing or invalid. */
|
||||
export function parseCoords(params: URLSearchParams): { lat: number; lon: number } | null {
|
||||
const rawLat = params.get("lat")?.trim() ?? "";
|
||||
const rawLon = params.get("lon")?.trim() ?? "";
|
||||
if (!rawLat || !rawLon) return null;
|
||||
|
||||
const lat = Number(rawLat);
|
||||
const lon = Number(rawLon);
|
||||
const valid = Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180;
|
||||
return valid ? { lat, lon } : null;
|
||||
}
|
||||
144
lib/weather/types.ts
Normal file
144
lib/weather/types.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* The one shape the UI understands.
|
||||
*
|
||||
* Every provider returns wildly different JSON. Each adapter in ./providers
|
||||
* translates its provider's response into these types, so the UI never has
|
||||
* to know which provider it is talking to.
|
||||
*
|
||||
* Rules every adapter follows:
|
||||
* - Units are metric: °C, km/h, hPa, km, mm.
|
||||
* - Times are ISO-8601 instants in UTC ("2026-10-21T23:00:00.000Z").
|
||||
* The UI formats them in the location's time zone.
|
||||
* - A reading the provider doesn't offer is `null`, and the UI hides it.
|
||||
*/
|
||||
|
||||
export type ProviderId = "openweather" | "weatherapi" | "tomorrow" | "openmeteo";
|
||||
|
||||
/** A deliberately small set of conditions — one per pixel icon. */
|
||||
export type Condition = "clear" | "clouds" | "rain" | "storm" | "snow" | "fog";
|
||||
|
||||
export interface Place {
|
||||
name: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export interface CurrentWeather {
|
||||
time: string;
|
||||
tempC: number;
|
||||
feelsLikeC: number;
|
||||
condition: Condition;
|
||||
description: string; // human words, e.g. "light rain"
|
||||
isDay: boolean;
|
||||
humidity: number; // %
|
||||
windKph: number;
|
||||
gustKph: number | null;
|
||||
windDeg: number | null; // direction the wind blows FROM, 0 = north
|
||||
pressureHpa: number | null;
|
||||
dewPointC: number | null;
|
||||
visibilityKm: number | null;
|
||||
uvIndex: number | null;
|
||||
cloudCover: number | null; // %
|
||||
precipMm: number | null; // in the last hour
|
||||
}
|
||||
|
||||
export interface HourlyForecast {
|
||||
time: string; // start of the hour (or 3-hour block)
|
||||
tempC: number;
|
||||
feelsLikeC: number | null;
|
||||
condition: Condition;
|
||||
isDay: boolean;
|
||||
precipChance: number | null; // %
|
||||
precipMm: number | null;
|
||||
windKph: number;
|
||||
gustKph: number | null;
|
||||
windDeg: number | null;
|
||||
humidity: number | null;
|
||||
uvIndex: number | null;
|
||||
}
|
||||
|
||||
export interface DailyForecast {
|
||||
time: string; // any instant inside that local day (midday where possible)
|
||||
highC: number;
|
||||
lowC: number;
|
||||
condition: Condition;
|
||||
summary: string | null; // provider's own one-liner, if it has one
|
||||
precipChance: number | null;
|
||||
precipMm: number | null;
|
||||
windKph: number | null; // daily max
|
||||
gustKph: number | null;
|
||||
windDeg: number | null;
|
||||
uvIndexMax: number | null;
|
||||
humidity: number | null; // daily average
|
||||
sunrise: string | null;
|
||||
sunset: string | null;
|
||||
}
|
||||
|
||||
export type AlertSeverity = "extreme" | "severe" | "moderate" | "minor" | "unknown";
|
||||
|
||||
export interface WeatherAlert {
|
||||
title: string;
|
||||
severity: AlertSeverity;
|
||||
source: string; // who issued it, e.g. "NWS Twin Cities/Chanhassen MN"
|
||||
starts: string | null;
|
||||
ends: string | null;
|
||||
description: string;
|
||||
instruction: string | null;
|
||||
}
|
||||
|
||||
export interface AirQuality {
|
||||
usAqi: number; // US EPA scale, 0–500
|
||||
pm25: number | null; // µg/m³
|
||||
pm10: number | null;
|
||||
ozone: number | null;
|
||||
no2: number | null;
|
||||
}
|
||||
|
||||
/** What one provider adapter returns. */
|
||||
export interface ProviderForecast {
|
||||
current: CurrentWeather;
|
||||
hourly: HourlyForecast[];
|
||||
daily: DailyForecast[];
|
||||
hourlyStepHours: 1 | 3;
|
||||
timeZone: string | null; // IANA name, if the provider tells us
|
||||
alerts: WeatherAlert[];
|
||||
}
|
||||
|
||||
/** What /api/weather returns: the provider's forecast plus extras from other sources. */
|
||||
export interface WeatherReport extends ProviderForecast {
|
||||
provider: ProviderId;
|
||||
timeZone: string | null;
|
||||
airQuality: AirQuality | null;
|
||||
alertSource: string | null;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/** The contract each provider adapter fulfils. */
|
||||
export interface WeatherProvider {
|
||||
id: ProviderId;
|
||||
label: string;
|
||||
/** Name of the env var holding its key, or null if no key is needed. */
|
||||
envKey: string | null;
|
||||
signupUrl: string;
|
||||
/** What this provider can deliver on the plan configured in .env.local. */
|
||||
limits(): { forecastDays: number; hourlyStepHours: 1 | 3 };
|
||||
getForecast(lat: number, lon: number): Promise<ProviderForecast>;
|
||||
}
|
||||
|
||||
/** Safe-to-share provider info for the browser (never includes keys). */
|
||||
export interface ProviderInfo {
|
||||
id: ProviderId;
|
||||
label: string;
|
||||
ready: boolean;
|
||||
signupUrl: string;
|
||||
forecastDays: number;
|
||||
hourlyStepHours: 1 | 3;
|
||||
}
|
||||
|
||||
/** Map overlays the radar panel can show. */
|
||||
export interface RadarFrame {
|
||||
time: string;
|
||||
url: string; // Leaflet tile template with {z}/{x}/{y}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue