Skip to content

src/getPollen.ts

271 lines · 7.0 KB · TypeScript
Raw
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<Record<PollenType, PollenLevel>>
	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<PollenHourlyKey, PollenType> = {
	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<GeoResult> {
	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<string, string>
	hourly?: {
		time: string[]
	} & Partial<Record<PollenHourlyKey, Array<number | null>>>
	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<GetPollenResult> {
	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