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<string, unknown> | 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