Skip to content

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

Package listing

@kody/environment

src/open-meteo.ts

222 lines · 7.0 KB · TypeScript
import { packageStorage } from 'kody:runtime'
import { calendarDateInTimeZone, defaultLocationTimeZone } from './date.ts'
import { fetchJsonWithRetry, type RetryOptions } from './open-meteo-client.ts'

/**
 * Package-storage key for the weather cache. This lived in the
 * `environmentWeatherCache` user value until 2026-07-27; values are now
 * capped at 64 KiB (oversized D1 rows made production backups
 * un-restorable), and this cache regularly exceeded that.
 */
const weatherCacheStorageKey = 'weatherCache'
const weatherCacheRetentionDays = 21
const weatherCacheMaxRecords = 120
const coordinateToleranceDeg = 0.01

function roundCoordinate(value: unknown): number | null {
	const numeric = Number(value)
	if (!Number.isFinite(numeric)) return null
	return Math.round(numeric * 1e4) / 1e4
}

async function readWeatherCache(): Promise<{
	records: any[]
	updatedAt: string | null
}> {
	try {
		const parsed = (await packageStorage().get(weatherCacheStorageKey)) as any
		if (!parsed || typeof parsed !== 'object') {
			return { records: [], updatedAt: null }
		}
		return {
			records: Array.isArray(parsed.records) ? parsed.records : [],
			updatedAt: parsed.updatedAt || null,
		}
	} catch {
		return { records: [], updatedAt: null }
	}
}

async function writeWeatherCache(cache: {
	records: any[]
	updatedAt: string | null
}) {
	await packageStorage().set(weatherCacheStorageKey, cache)
}

function pruneWeatherRecords(records: any[]) {
	const retainAfter = Date.now() - weatherCacheRetentionDays * 864e5
	return records
		.filter((record) => {
			if (!record || typeof record !== 'object') return false
			const recordedAt = Date.parse(record.recordedAt || '')
			return Number.isFinite(recordedAt) && recordedAt >= retainAfter
		})
		.sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt))
		.slice(0, weatherCacheMaxRecords)
}

function findWeatherCacheRecord(records: any[], input: any) {
	const lat = roundCoordinate(input.latitude)
	const lon = roundCoordinate(input.longitude)
	if (lat == null || lon == null) return null
	let best = null
	let bestRecorded = -Infinity
	for (const record of records) {
		if (!record || typeof record !== 'object') continue
		if (record.date !== input.date || record.units !== input.units) continue
		if (Math.abs(Number(record.lat) - lat) > coordinateToleranceDeg) continue
		if (Math.abs(Number(record.lon) - lon) > coordinateToleranceDeg) continue
		const recorded = Date.parse(record.recordedAt || '')
		if (!Number.isFinite(recorded) || recorded <= bestRecorded) continue
		best = record
		bestRecorded = recorded
	}
	return best
}

async function recordWeatherData(input: any, result: any) {
	try {
		const lat = roundCoordinate(input.latitude)
		const lon = roundCoordinate(input.longitude)
		if (lat == null || lon == null) return
		const cache = await readWeatherCache()
		const recordedAt = new Date().toISOString()
		cache.records.push({
			lat,
			lon,
			date: input.date,
			units: result.units,
			data: result.data,
			tempUnit: result.tempUnit,
			windUnit: result.windUnit,
			precipUnit: result.precipUnit,
			isHistorical: result.isHistorical,
			isForecast: result.isForecast,
			recordedAt,
			source: 'open-meteo',
		})
		cache.records = pruneWeatherRecords(cache.records)
		cache.updatedAt = recordedAt
		await writeWeatherCache(cache)
	} catch {
		// Cache writes are best-effort; live results still return on failure.
	}
}

export type FetchWeatherDataParams = {
	latitude: number
	longitude: number
	date: string
	units?: 'metric' | 'imperial'
	allowCache?: boolean
	live?: RetryOptions
}

export async function fetchWeatherData(params: FetchWeatherDataParams) {
	const input = typeof params === 'object' && params ? params : ({} as any)
	const units = input.units === 'metric' ? 'metric' : 'imperial'
	const tempUnit = units === 'metric' ? 'celsius' : 'fahrenheit'
	const windUnit = units === 'metric' ? 'kmh' : 'mph'
	const precipUnit = units === 'metric' ? 'mm' : 'inch'
	const hourlyVars = [
		'temperature_2m',
		'apparent_temperature',
		'precipitation_probability',
		'precipitation',
		'windspeed_10m',
		'winddirection_10m',
		'weathercode',
		'relativehumidity_2m',
		'uv_index',
	].join(',')
	const forecastOnlyDailyVars = ['precipitation_probability_max', 'uv_index_max']
	const baseDailyVars = [
		'temperature_2m_max',
		'temperature_2m_min',
		'apparent_temperature_max',
		'apparent_temperature_min',
		'precipitation_sum',
		'windspeed_10m_max',
		'sunrise',
		'sunset',
		'weathercode',
	]
	const today = calendarDateInTimeZone(new Date(), defaultLocationTimeZone)
	const isHistorical = String(input.date) < today
	const dailyVars = (
		isHistorical ? baseDailyVars : [...baseDailyVars, ...forecastOnlyDailyVars]
	).join(',')
	const commonParams = [
		'latitude=' + encodeURIComponent(String(input.latitude)),
		'longitude=' + encodeURIComponent(String(input.longitude)),
		'start_date=' + encodeURIComponent(String(input.date)),
		'end_date=' + encodeURIComponent(String(input.date)),
		'hourly=' + encodeURIComponent(hourlyVars),
		'daily=' + encodeURIComponent(dailyVars),
		'temperature_unit=' + encodeURIComponent(tempUnit),
		'windspeed_unit=' + encodeURIComponent(windUnit),
		'precipitation_unit=' + encodeURIComponent(precipUnit),
		'timezone=auto',
	].join('&')
	const isForecast = String(input.date) > today
	const currentParams = isHistorical ? '' : '&current=temperature_2m,weathercode'
	const endpoint = isHistorical
		? 'https://archive-api.open-meteo.com/v1/archive?' + commonParams
		: 'https://api.open-meteo.com/v1/forecast?' + commonParams + currentParams
	let liveError: Error | null = null
	try {
		const live = await fetchJsonWithRetry(endpoint, input.live || {})
		const data = live.data
		if (data.error) {
			throw Object.assign(
				new Error('Open-Meteo error: ' + (data.reason || JSON.stringify(data))),
				{ retryable: false },
			)
		}
		if (!data.daily || !Array.isArray(data.daily.weathercode)) {
			throw Object.assign(new Error('Unexpected weather response shape.'), {
				retryable: false,
			})
		}
		const result = {
			data,
			units,
			tempUnit,
			windUnit,
			precipUnit,
			isHistorical,
			isForecast,
			source: 'open-meteo',
			stale: false,
			attempts: live.attempts,
			recordedAt: new Date().toISOString(),
			fallbackError: null as string | null,
		}
		await recordWeatherData({ ...input, units }, result)
		return result
	} catch (error) {
		liveError = error instanceof Error ? error : new Error(String(error))
	}
	if (input.allowCache !== false) {
		const cache = await readWeatherCache()
		const record = findWeatherCacheRecord(cache.records, { ...input, units })
		if (record && record.data) {
			return {
				data: record.data,
				units,
				tempUnit: record.tempUnit || tempUnit,
				windUnit: record.windUnit || windUnit,
				precipUnit: record.precipUnit || precipUnit,
				isHistorical: Boolean(record.isHistorical),
				isForecast: Boolean(record.isForecast),
				source: 'cache',
				stale: true,
				attempts: null,
				recordedAt: record.recordedAt || null,
				fallbackError: liveError ? liveError.message : null,
			}
		}
	}
	throw liveError || new Error('Weather unavailable for ' + input.date)
}