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)
}