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<number, string> = {
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<GeoHit> {
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<Record<string, unknown>>
} | 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<WeatherSnapshot> {
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<string, unknown>
daily?: Record<string, unknown>
} | 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<HealthSnapshot> {
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<string, unknown>
} | 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,
}
}