Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/morning-briefing

src/open-meteo.ts

181 lines · 5.0 KB · TypeScript
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 +
		'&current=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 +
		'&current=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,
	}
}