export type GetPollenInput = { /** Place name to geocode via Open-Meteo (e.g. "Berlin"). Ignored when latitude+longitude are set. */ location?: string latitude?: number longitude?: number /** Forecast hours to scan for peak (1–96, default 24). Current/next hour is always included. */ hours?: number } export type PollenType = | 'alder' | 'birch' | 'grass' | 'mugwort' | 'olive' | 'ragweed' export type PollenLevelLabel = 'none' | 'low' | 'moderate' | 'high' | 'very_high' export type PollenLevel = { value: number unit: 'grains/m³' label: PollenLevelLabel } export type GetPollenResult = { place: { name: string country?: string admin1?: string timezone?: string } | null coords: { latitude: number; longitude: number } time: string timezone: string /** Open-Meteo CAMS pollen is Europe-focused; outside Europe values are typically null. */ coverage: 'available' | 'unavailable' levels: Partial> peak?: { time: string type: PollenType value: number label: PollenLevelLabel } | null summary: string attribution: string } const POLLEN_KEYS = [ 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen', ] as const type PollenHourlyKey = (typeof POLLEN_KEYS)[number] const KEY_TO_TYPE: Record = { alder_pollen: 'alder', birch_pollen: 'birch', grass_pollen: 'grass', mugwort_pollen: 'mugwort', olive_pollen: 'olive', ragweed_pollen: 'ragweed', } /** Simplified grain/m³ bands for a brief human label (not clinical advice). */ function labelFor(value: number): PollenLevelLabel { if (value <= 0) return 'none' if (value < 10) return 'low' if (value < 50) return 'moderate' if (value < 100) return 'high' return 'very_high' } type GeoResult = { name: string latitude: number longitude: number country?: string admin1?: string timezone?: string } async function geocode(name: string): Promise { const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=1&language=en&format=json` const res = await fetch(url) if (!res.ok) { throw new Error(`Geocoding failed: HTTP ${res.status}`) } const data = (await res.json()) as { results?: Array<{ name: string latitude: number longitude: number country?: string admin1?: string timezone?: string }> } const hit = data.results?.[0] if (!hit) { throw new Error(`Place not found: ${name}`) } return { name: hit.name, latitude: hit.latitude, longitude: hit.longitude, country: hit.country, admin1: hit.admin1, timezone: hit.timezone, } } type AirQualityResponse = { latitude: number longitude: number timezone?: string hourly_units?: Record hourly?: { time: string[] } & Partial>> error?: boolean reason?: string } /** * Fetch pollen grain levels for a city or lat/lon via Open-Meteo Air Quality API. * Use when you need a slim allergy/pollen pulse. Coverage is Europe-focused * (CAMS); outside Europe hourly pollen fields are usually null. * * @param input - `location` and/or `latitude`+`longitude`, optional `hours` * @returns Place, coords, time, levels per pollen type present, brief summary * * @example * import { getPollen } from 'kody:@reng/pollen/getPollen' * * const berlin = await getPollen({ location: 'Berlin', hours: 24 }) * // { place: { name: 'Berlin', … }, levels: { grass: { value, label }, … }, summary: '…' } */ export async function getPollen(input: GetPollenInput = {}): Promise { const hours = Math.min(96, Math.max(1, Math.floor(input.hours ?? 24))) let place: GetPollenResult['place'] = null let latitude = input.latitude let longitude = input.longitude const hasCoords = typeof latitude === 'number' && Number.isFinite(latitude) && typeof longitude === 'number' && Number.isFinite(longitude) if (!hasCoords) { const loc = input.location?.trim() if (!loc) { throw new Error('Provide location (place name) or latitude and longitude') } const geo = await geocode(loc) latitude = geo.latitude longitude = geo.longitude place = { name: geo.name, country: geo.country, admin1: geo.admin1, timezone: geo.timezone, } } else if (input.location?.trim()) { place = { name: input.location.trim() } } const hourlyParam = POLLEN_KEYS.join(',') const aqUrl = `https://air-quality-api.open-meteo.com/v1/air-quality` + `?latitude=${latitude}` + `&longitude=${longitude}` + `&hourly=${hourlyParam}` + `&forecast_hours=${hours}` + `&timezone=auto` const res = await fetch(aqUrl) if (!res.ok) { throw new Error(`Air quality API failed: HTTP ${res.status}`) } const data = (await res.json()) as AirQualityResponse if (data.error) { throw new Error(data.reason || 'Air quality API error') } const times = data.hourly?.time ?? [] if (!times.length) { throw new Error('No hourly pollen data returned') } // forecast_hours starts at the current hour — use the first timestep. const idx = 0 const levels: GetPollenResult['levels'] = {} let anyNumeric = false for (const key of POLLEN_KEYS) { const series = data.hourly?.[key] const raw = series?.[idx] if (typeof raw !== 'number' || !Number.isFinite(raw)) continue anyNumeric = true const type = KEY_TO_TYPE[key] levels[type] = { value: raw, unit: 'grains/m³', label: labelFor(raw), } } let peak: GetPollenResult['peak'] = null if (anyNumeric) { for (let i = 0; i < times.length; i++) { for (const key of POLLEN_KEYS) { const raw = data.hourly?.[key]?.[i] if (typeof raw !== 'number' || !Number.isFinite(raw)) continue if (!peak || raw > peak.value) { peak = { time: times[i]!, type: KEY_TO_TYPE[key], value: raw, label: labelFor(raw), } } } } } const coverage: GetPollenResult['coverage'] = anyNumeric ? 'available' : 'unavailable' const time = times[idx]! const timezone = data.timezone || place?.timezone || 'GMT' let summary: string if (!anyNumeric) { summary = 'No pollen data for this location. Open-Meteo CAMS pollen covers Europe (and limited areas) during pollen season; values elsewhere are typically null.' } else { const parts = (Object.entries(levels) as Array<[PollenType, PollenLevel]>) .filter(([, lv]) => lv.value > 0) .sort((a, b) => b[1].value - a[1].value) .map(([type, lv]) => `${type} ${lv.label} (${lv.value} grains/m³)`) if (parts.length === 0) { summary = `Pollen near none at ${time} (${timezone}).` } else { summary = `At ${time} (${timezone}): ${parts.join('; ')}.` if (peak && peak.time !== time) { summary += ` Peak in window: ${peak.type} ${peak.label} (${peak.value}) at ${peak.time}.` } } } return { place, coords: { latitude: latitude!, longitude: longitude! }, time, timezone, coverage, levels, peak: anyNumeric ? peak : null, summary, attribution: 'Data: Open-Meteo Air Quality API / CAMS European air quality forecasts. Not medical advice.', } } export default getPollen