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 ? '' : '¤t=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) }