Skip to content
← Public packages

@kody/local-conditions

Quick onboarding example: weather and AQI for a city via Open-Meteo (zero OAuth).

src/index.ts

195 lines · 5.6 KB · TypeScript
import { kody } from 'kody:runtime'

export type LocalConditionsInput = {
	/** Free-form city or place name to geocode. */
	place: string
}

export type LocalConditionsResult = {
	place: string
	country?: string
	admin1?: string
	latitude: number
	longitude: number
	timezone: string
	current: {
		time: string
		temperatureC: number | null
		humidityPct: number | null
		windSpeedKmh: number | null
		weatherCode: number | null
		weatherLabel: string
	}
	aqi?: {
		time: string
		usAqi: number | null
		pm25: number | null
		pm10: number | null
	}
	sunrise?: string
	sunset?: 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',
}

function requirePlace(place: unknown): string {
	if (typeof place !== 'string' || place.trim() === '') {
		throw new Error('place is required (e.g. "Salt Lake City").')
	}
	return place.trim()
}

function num(value: unknown): number | null {
	const n = Number(value)
	return Number.isFinite(n) ? n : null
}

/** Geocode a place and return current weather, AQI, and sun times from Open-Meteo. */
export async function getLocalConditions(
	input: LocalConditionsInput,
): Promise<LocalConditionsResult> {
	const place = requirePlace(input?.place)
	const geoRes = await fetch(
		'https://geocoding-api.open-meteo.com/v1/search?name=' +
			encodeURIComponent(place) +
			'&count=1&language=en&format=json',
	)
	const geo = await geoRes.json().catch(() => null)
	const hit = geo?.results?.[0]
	if (!geoRes.ok || !hit) {
		throw new Error('Could not geocode place: "' + place + '"')
	}

	const lat = Number(hit.latitude)
	const lon = Number(hit.longitude)
	const timezone = String(hit.timezone || 'UTC')
	const label = [hit.name, hit.admin1, hit.country]
		.filter(Boolean)
		.join(', ')

	const forecastUrl =
		'https://api.open-meteo.com/v1/forecast?latitude=' +
		lat +
		'&longitude=' +
		lon +
		'&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m' +
		'&daily=sunrise,sunset&timezone=' +
		encodeURIComponent(timezone) +
		'&forecast_days=1'
	const aqiUrl =
		'https://air-quality-api.open-meteo.com/v1/air-quality?latitude=' +
		lat +
		'&longitude=' +
		lon +
		'&current=us_aqi,pm2_5,pm10'

	const [forecastRes, aqiRes] = await Promise.all([
		fetch(forecastUrl),
		fetch(aqiUrl),
	])
	const forecast = await forecastRes.json().catch(() => null)
	const aqiBody = await aqiRes.json().catch(() => null)
	if (!forecastRes.ok || !forecast?.current) {
		throw new Error('Open-Meteo forecast request failed for "' + label + '"')
	}

	const code = num(forecast.current.weather_code)
	const result: LocalConditionsResult = {
		place: label || place,
		country: hit.country ? String(hit.country) : undefined,
		admin1: hit.admin1 ? String(hit.admin1) : undefined,
		latitude: lat,
		longitude: lon,
		timezone,
		current: {
			time: String(forecast.current.time || ''),
			temperatureC: num(forecast.current.temperature_2m),
			humidityPct: num(forecast.current.relative_humidity_2m),
			windSpeedKmh: num(forecast.current.wind_speed_10m),
			weatherCode: code,
			weatherLabel: (code !== null && WMO[code]) || 'Unknown',
		},
		sunrise: forecast.daily?.sunrise?.[0]
			? String(forecast.daily.sunrise[0])
			: undefined,
		sunset: forecast.daily?.sunset?.[0]
			? String(forecast.daily.sunset[0])
			: undefined,
		attribution:
			'Weather and air quality data by Open-Meteo.com (CC BY 4.0)',
	}

	if (aqiRes.ok && aqiBody?.current) {
		result.aqi = {
			time: String(aqiBody.current.time || ''),
			usAqi: num(aqiBody.current.us_aqi),
			pm25: num(aqiBody.current.pm2_5),
			pm10: num(aqiBody.current.pm10),
		}
	}

	return result
}

/** Fetch local conditions and email a short summary to your Kody inbox. */
export async function emailLocalConditions(
	input: LocalConditionsInput,
): Promise<{ emailed: true; subject: string; conditions: LocalConditionsResult }> {
	const conditions = await getLocalConditions(input)
	const aqiPart =
		conditions.aqi?.usAqi != null ? `, US AQI ${conditions.aqi.usAqi}` : ''
	const subject =
		`Local conditions: ${conditions.place} — ${conditions.current.temperatureC}°C, ${conditions.current.weatherLabel}${aqiPart}`
	const text = [
		conditions.place,
		`Timezone: ${conditions.timezone}`,
		`Temp: ${conditions.current.temperatureC}°C`,
		`Humidity: ${conditions.current.humidityPct}%`,
		`Wind: ${conditions.current.windSpeedKmh} km/h`,
		`Sky: ${conditions.current.weatherLabel}`,
		conditions.aqi
			? `US AQI: ${conditions.aqi.usAqi} (PM2.5 ${conditions.aqi.pm25}, PM10 ${conditions.aqi.pm10})`
			: null,
		conditions.sunrise ? `Sunrise: ${conditions.sunrise}` : null,
		conditions.sunset ? `Sunset: ${conditions.sunset}` : null,
		'',
		conditions.attribution,
	]
		.filter((line) => line !== null)
		.join('\n')

	await kody.emailSend({ subject, text })
	return { emailed: true, subject, conditions }
}

/** Root dispatcher for packages.invoke({ exportName: '.', params }). */
export default async function localConditions(input: LocalConditionsInput & { action?: string } = { place: '' }) {
	const action = String(input?.action || 'getLocalConditions')
	if (action === 'emailLocalConditions' || action === 'email') {
		return emailLocalConditions(input)
	}
	return getLocalConditions(input)
}