← 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 · TypeScriptimport { 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 +
'¤t=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 +
'¤t=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)
}