export type GeoHit = { name: string admin1?: string country?: string latitude: number longitude: number timezone: string } export type WeatherSnapshot = { place: string timezone: string current: { temperatureC: number | null weatherLabel: string windSpeedKmh: number | null humidityPct: number | null } daily: { highC: number | null lowC: number | null weatherLabel: string precipPct: number | null } attribution: string } export type HealthSnapshot = { place: string usAqi: number | null pm25: number | null pm10: number | null category: string attribution: string } const WMO: Record = { 0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast', 45: 'Fog', 48: 'Depositing rime fog', 51: 'Light drizzle', 53: 'Moderate drizzle', 55: 'Dense drizzle', 61: 'Slight rain', 63: 'Moderate rain', 65: 'Heavy rain', 71: 'Slight snow', 73: 'Moderate snow', 75: 'Heavy snow', 80: 'Rain showers', 81: 'Moderate rain showers', 82: 'Violent rain showers', 95: 'Thunderstorm', 96: 'Thunderstorm with slight hail', 99: 'Thunderstorm with heavy hail', } export const OPEN_METEO_ATTRIBUTION = 'Weather and air quality data by Open-Meteo.com (CC BY 4.0)' export function num(value: unknown): number | null { const n = Number(value) return Number.isFinite(n) ? n : null } export function weatherLabel(code: unknown): string { const n = num(code) if (n === null) return 'Unknown' return WMO[n] || 'Unknown' } export function aqiCategory(aqi: number | null): string { if (aqi === null) return 'Unknown' if (aqi <= 50) return 'Good' if (aqi <= 100) return 'Moderate' if (aqi <= 150) return 'Unhealthy for sensitive groups' if (aqi <= 200) return 'Unhealthy' if (aqi <= 300) return 'Very unhealthy' return 'Hazardous' } export function placeLabel(hit: GeoHit, fallback: string): string { return [hit.name, hit.admin1, hit.country].filter(Boolean).join(', ') || fallback } export async function geocodePlace(place: string): Promise { const response = await fetch( 'https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(place) + '&count=1&language=en&format=json', ) const body = (await response.json().catch(() => null)) as { results?: Array> } | null const hit = body?.results?.[0] if (!response.ok || !hit) { throw new Error(`Could not geocode place: "${place}"`) } return { name: String(hit.name || place), admin1: hit.admin1 ? String(hit.admin1) : undefined, country: hit.country ? String(hit.country) : undefined, latitude: Number(hit.latitude), longitude: Number(hit.longitude), timezone: String(hit.timezone || 'UTC'), } } export async function fetchWeather(place: string): Promise { const hit = await geocodePlace(place) const label = placeLabel(hit, place) const url = 'https://api.open-meteo.com/v1/forecast?latitude=' + hit.latitude + '&longitude=' + hit.longitude + '¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m' + '&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max' + '&timezone=' + encodeURIComponent(hit.timezone) + '&forecast_days=1' const response = await fetch(url) const body = (await response.json().catch(() => null)) as { current?: Record daily?: Record } | null if (!response.ok || !body?.current) { throw new Error(`Open-Meteo forecast request failed for "${label}"`) } const daily = body.daily || {} return { place: label, timezone: hit.timezone, current: { temperatureC: num(body.current.temperature_2m), weatherLabel: weatherLabel(body.current.weather_code), windSpeedKmh: num(body.current.wind_speed_10m), humidityPct: num(body.current.relative_humidity_2m), }, daily: { highC: num(Array.isArray(daily.temperature_2m_max) ? daily.temperature_2m_max[0] : null), lowC: num(Array.isArray(daily.temperature_2m_min) ? daily.temperature_2m_min[0] : null), weatherLabel: weatherLabel(Array.isArray(daily.weather_code) ? daily.weather_code[0] : null), precipPct: num( Array.isArray(daily.precipitation_probability_max) ? daily.precipitation_probability_max[0] : null, ), }, attribution: OPEN_METEO_ATTRIBUTION, } } export async function fetchHealth(place: string): Promise { const hit = await geocodePlace(place) const label = placeLabel(hit, place) const url = 'https://air-quality-api.open-meteo.com/v1/air-quality?latitude=' + hit.latitude + '&longitude=' + hit.longitude + '¤t=us_aqi,pm2_5,pm10' const response = await fetch(url) const body = (await response.json().catch(() => null)) as { current?: Record } | null if (!response.ok || !body?.current) { throw new Error(`Open-Meteo air-quality request failed for "${label}"`) } const usAqi = num(body.current.us_aqi) return { place: label, usAqi, pm25: num(body.current.pm2_5), pm10: num(body.current.pm10), category: aqiCategory(usAqi), attribution: OPEN_METEO_ATTRIBUTION, } }