import { packageStorage } from 'kody:runtime' /** * Package-storage key for the forecast history cache. This lived in the * `environmentForecastHistory` 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 HISTORY_STORAGE_KEY = 'forecastHistory' const HISTORY_RETENTION_DAYS = 90 const HISTORY_MAX_RECORDS = 500 const DEFAULT_RETRY_ATTEMPTS = 4 const DEFAULT_RETRY_BASE_DELAY_MS = 500 const DEFAULT_FORECAST_DAYS = 3 const DEFAULT_SEASONAL_MEAN_F = 60 const DEFAULT_SEASONAL_AMPLITUDE_F = 25 const DEFAULT_SEASONAL_PEAK_DAY = 200 const COORDINATE_TOLERANCE_DEG = 0.01 export type RecordForecastSeriesParams = { latitude: number longitude: number series: Array<{ date: string; highF?: number | null; lowF?: number | null }> source?: string } export type RecentForecastRecordParams = { latitude: number longitude: number date: string } export type ForecastHighParams = { latitude: number longitude: number date: string units?: 'metric' | 'imperial' timezone?: string allowHistory?: boolean allowSeasonal?: boolean seasonal?: { mean?: number; amplitude?: number; peakDay?: number } live?: { forecastDays?: number; attempts?: number; baseDelayMs?: number } } function dayOfYear(dateText: string): number | null { const pieces = String(dateText) .split('-') .map((value) => Number(value)) if (pieces.length !== 3) return null const [year, month, day] = pieces if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { return null } const start = Date.UTC(year, 0, 1) const cur = Date.UTC(year, month - 1, day) return Math.floor((cur - start) / 864e5) + 1 } function roundCoordinate(value: unknown): number | null { const num = Number(value) if (!Number.isFinite(num)) return null return Math.round(num * 1e4) / 1e4 } function fahrenheitToCelsius(value: unknown): number | null { if (typeof value !== 'number' || !Number.isFinite(value)) return null return Math.round(((value - 32) * 5) / 9 * 10) / 10 } async function readHistory(): Promise<{ records: any[] updatedAt: string | null }> { try { const parsed = (await packageStorage().get(HISTORY_STORAGE_KEY)) as any if (!parsed || typeof parsed !== 'object') { return { records: [], updatedAt: null } } const records = Array.isArray(parsed.records) ? parsed.records : [] return { records, updatedAt: parsed.updatedAt || null } } catch { return { records: [], updatedAt: null } } } async function writeHistory(history: { records: any[]; updatedAt: string | null }) { await packageStorage().set(HISTORY_STORAGE_KEY, history) } function pruneRecords(records: any[]) { const retainAfter = Date.now() - HISTORY_RETENTION_DAYS * 864e5 const filtered = [] for (const record of records) { if (!record || typeof record !== 'object') continue const recordedAt = Date.parse(record.recordedAt || '') if (!Number.isFinite(recordedAt)) continue if (recordedAt < retainAfter) continue filtered.push(record) } filtered.sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt)) return filtered.slice(0, HISTORY_MAX_RECORDS) } function findBestRecord( records: any[], latitude: unknown, longitude: unknown, date: unknown, ) { const lat = roundCoordinate(latitude) const lon = roundCoordinate(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 !== date) continue if (Math.abs(Number(record.lat) - lat) > COORDINATE_TOLERANCE_DEG) continue if (Math.abs(Number(record.lon) - lon) > COORDINATE_TOLERANCE_DEG) continue const recordedAt = Date.parse(record.recordedAt || '') if (!Number.isFinite(recordedAt)) continue if (recordedAt > bestRecorded) { bestRecorded = recordedAt best = record } } return best } async function fetchOpenMeteoForecastDailyHighsF(params: { latitude: number longitude: number timezone?: string forecastDays?: number attempts?: number baseDelayMs?: number }) { const url = 'https://api.open-meteo.com/v1/forecast?latitude=' + encodeURIComponent(String(params.latitude)) + '&longitude=' + encodeURIComponent(String(params.longitude)) + '&daily=temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&timezone=' + encodeURIComponent(params.timezone || 'auto') + '&forecast_days=' + encodeURIComponent(String(params.forecastDays || DEFAULT_FORECAST_DAYS)) const attempts = Number.isFinite(params.attempts) ? Math.max(1, Math.floor(params.attempts as number)) : DEFAULT_RETRY_ATTEMPTS const baseDelayMs = Number.isFinite(params.baseDelayMs) ? Math.max(0, params.baseDelayMs as number) : DEFAULT_RETRY_BASE_DELAY_MS let lastError: Error | null = null for (let attempt = 1; attempt <= attempts; attempt += 1) { try { const response = await fetch(url) if (!response.ok) { throw new Error( 'Open-Meteo forecast request failed for ' + url + ': ' + response.status, ) } const data = await response.json() if ( !data || !data.daily || !Array.isArray(data.daily.time) || !Array.isArray(data.daily.temperature_2m_max) ) { throw new Error('Unexpected Open-Meteo response shape.') } const dates = data.daily.time const highs = data.daily.temperature_2m_max const lows = Array.isArray(data.daily.temperature_2m_min) ? data.daily.temperature_2m_min : [] const series = [] for (let i = 0; i < dates.length; i += 1) { const high = Number(highs[i]) if (!Number.isFinite(high)) continue const low = lows[i] != null ? Number(lows[i]) : null series.push({ date: dates[i], highF: high, lowF: typeof low === 'number' && Number.isFinite(low) ? low : null, }) } return { series, attempts: attempt, error: null } } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) if (attempt < attempts) { await new Promise((resolve) => setTimeout(resolve, baseDelayMs * Math.pow(2, attempt - 1)), ) } } } throw lastError } function defaultSeasonalHighF( date: string, opts?: { mean?: number; amplitude?: number; peakDay?: number }, ): number | null { const day = dayOfYear(date) if (day == null) return null const mean = Number.isFinite(Number(opts && opts.mean)) ? Number(opts!.mean) : DEFAULT_SEASONAL_MEAN_F const amplitude = Number.isFinite(Number(opts && opts.amplitude)) ? Number(opts!.amplitude) : DEFAULT_SEASONAL_AMPLITUDE_F const peakDay = Number.isFinite(Number(opts && opts.peakDay)) ? Number(opts!.peakDay) : DEFAULT_SEASONAL_PEAK_DAY return ( Math.round( (mean + amplitude * Math.cos((2 * Math.PI * (day - peakDay)) / 365)) * 10, ) / 10 ) } /** Persist a forecast series into the durable history value for later fallback reads. */ export async function recordForecastSeries( params: RecordForecastSeriesParams = {} as RecordForecastSeriesParams, ): Promise<{ written: number; totalRecords: number }> { const input = typeof params === 'object' && params ? params : ({} as any) const lat = roundCoordinate(input.latitude) const lon = roundCoordinate(input.longitude) const series = Array.isArray(input.series) ? input.series : [] if (lat == null || lon == null || series.length === 0) { return { written: 0, totalRecords: 0 } } const history = await readHistory() const recordedAt = new Date().toISOString() const source = typeof input.source === 'string' && input.source ? input.source : 'open-meteo' let written = 0 for (const point of series) { if (!point || typeof point !== 'object' || typeof point.date !== 'string') { continue } const highF = typeof point.highF === 'number' && Number.isFinite(point.highF) ? point.highF : null const lowF = typeof point.lowF === 'number' && Number.isFinite(point.lowF) ? point.lowF : null if (highF == null && lowF == null) continue history.records.push({ lat, lon, date: point.date, highF, lowF, recordedAt, source, }) written += 1 } history.records = pruneRecords(history.records) history.updatedAt = recordedAt await writeHistory(history) return { written, totalRecords: history.records.length } } /** Read the most recent stored forecast record for a coordinate and date. */ export async function getRecentForecastRecord( params: RecentForecastRecordParams = {} as RecentForecastRecordParams, ): Promise<{ match: Record | null; totalRecords: number }> { const input = typeof params === 'object' && params ? params : ({} as any) const history = await readHistory() const match = findBestRecord( history.records, input.latitude, input.longitude, input.date, ) return { match, totalRecords: history.records.length } } /** Forecast daily high with live, history, and seasonal-estimate fallbacks. */ export async function getForecastHigh( params: ForecastHighParams = {} as ForecastHighParams, ) { const input = typeof params === 'object' && params ? params : ({} as any) const latitude = Number(input.latitude) const longitude = Number(input.longitude) if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { throw new Error( 'forecast-history.getForecastHigh requires numeric latitude and longitude.', ) } const date = typeof input.date === 'string' && input.date ? input.date : null if (!date) { throw new Error( 'forecast-history.getForecastHigh requires date in YYYY-MM-DD form.', ) } const units = input.units === 'metric' ? 'metric' : 'imperial' const seasonal = (typeof input.seasonal === 'object' && input.seasonal) || {} const liveOptions = (typeof input.live === 'object' && input.live) || {} const allowHistory = input.allowHistory !== false const allowSeasonal = input.allowSeasonal !== false function toUnits(highF: number): number | null { if (units === 'metric') return fahrenheitToCelsius(highF) return highF } let liveError: Error | null = null let liveAttempts: number | null = null try { const live = await fetchOpenMeteoForecastDailyHighsF({ latitude, longitude, timezone: input.timezone, forecastDays: liveOptions.forecastDays, attempts: liveOptions.attempts, baseDelayMs: liveOptions.baseDelayMs, }) liveAttempts = live.attempts try { await recordForecastSeries({ latitude, longitude, series: live.series }) } catch { // History writes are best-effort. } const point = live.series.find((p) => p.date === date) if (point && typeof point.highF === 'number' && Number.isFinite(point.highF)) { return { value: toUnits(point.highF), units, date, source: 'open-meteo', recordedAt: new Date().toISOString(), attempts: liveAttempts, fallbackError: null as string | null, } } liveError = new Error('Open-Meteo response did not include date ' + date) } catch (error) { liveError = error instanceof Error ? error : new Error(String(error)) } if (allowHistory) { const recent = await getRecentForecastRecord({ latitude, longitude, date }) const match = recent.match as any if (match && typeof match.highF === 'number' && Number.isFinite(match.highF)) { const recordedAt = match.recordedAt const ageMs = Date.now() - Date.parse(recordedAt || '') return { value: toUnits(match.highF), units, date, source: 'history', recordedAt, ageHours: Number.isFinite(ageMs) ? Math.round(ageMs / 36e5) : null, fallbackError: liveError ? liveError.message : null, } } } if (allowSeasonal) { const seasonalHighF = defaultSeasonalHighF(date, seasonal) if (typeof seasonalHighF === 'number' && Number.isFinite(seasonalHighF)) { return { value: toUnits(seasonalHighF), units, date, source: 'seasonal-estimate', seasonal: { mean: Number.isFinite(Number(seasonal.mean)) ? Number(seasonal.mean) : DEFAULT_SEASONAL_MEAN_F, amplitude: Number.isFinite(Number(seasonal.amplitude)) ? Number(seasonal.amplitude) : DEFAULT_SEASONAL_AMPLITUDE_F, peakDay: Number.isFinite(Number(seasonal.peakDay)) ? Number(seasonal.peakDay) : DEFAULT_SEASONAL_PEAK_DAY, }, fallbackError: liveError ? liveError.message : null, } } } throw liveError || new Error('Forecast unavailable for ' + date) } export default getForecastHigh