import { createWriteStream, existsSync, mkdirSync } from 'fs' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' import { pipeline } from 'stream/promises' import { tzOffsetMinutes } from './config.mjs' const DIR = dirname(fileURLToPath(import.meta.url)) const ICON_DIR = resolve(DIR, 'public', 'icons') const CACHE = {} const CACHE_MS = 15 * 60 * 1000 if (!existsSync(ICON_DIR)) mkdirSync(ICON_DIR, { recursive: true }) const WMO = { 0: 'Clear Sky', 1: 'Mainly Clear', 2: 'Partly Cloudy', 3: 'Overcast', 45: 'Fog', 48: 'Icy Fog', 51: 'Light Drizzle', 53: 'Drizzle', 55: 'Heavy Drizzle', 61: 'Light Rain', 63: 'Rain', 65: 'Heavy Rain', 71: 'Light Snow', 73: 'Snow', 75: 'Heavy Snow', 77: 'Snow Grains', 80: 'Light Showers', 81: 'Showers', 82: 'Heavy Showers', 85: 'Snow Showers', 86: 'Heavy Snow Showers', 95: 'Thunderstorm', 96: 'Thunderstorm w/ Hail', 99: 'Thunderstorm w/ Heavy Hail', } const OWM_ICONS = { 0: '01d', 1: '01d', 2: '02d', 3: '04d', 45: '50d', 48: '50d', 51: '09d', 53: '09d', 55: '09d', 61: '10d', 63: '10d', 65: '10d', 71: '13d', 73: '13d', 75: '13d', 77: '13d', 80: '09d', 81: '09d', 82: '09d', 85: '13d', 86: '13d', 95: '11d', 96: '11d', 99: '11d', } async function ensureIcon(code) { const owmCode = OWM_ICONS[code] || '01d' const filename = `${owmCode}.png` const filepath = resolve(ICON_DIR, filename) if (!existsSync(filepath)) { const res = await fetch(`https://openweathermap.org/img/wn/${owmCode}@2x.png`) await pipeline(res.body, createWriteStream(filepath)) } return `/icons/${filename}` } async function cachedFetch(key, url) { const now = Date.now() if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data const res = await fetch(url) const data = await res.json() CACHE[key] = { ts: now, data } return data } function dewPoint(tempC, humidity) { const a = 17.27, b = 237.7 const gamma = (a * tempC) / (b + tempC) + Math.log(humidity / 100) return Math.round((b * gamma) / (a - gamma) * 100) / 100 } function heatIndex(tempC, humidity) { const T = tempC * 9 / 5 + 32 if (T < 80) return Math.round(tempC * 100) / 100 const HI = -42.379 + 2.04901523 * T + 10.14333127 * humidity - 0.22475541 * T * humidity - 0.00683783 * T * T - 0.05481717 * humidity * humidity + 0.00122874 * T * T * humidity + 0.00085282 * T * humidity * humidity - 0.00000199 * T * T * humidity * humidity return Math.round((HI - 32) * 5 / 9 * 100) / 100 } function vaporPressureDeficit(tempC, humidity) { const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3)) return Math.round(svp * (1 - humidity / 100) * 1000) / 1000 } function absoluteHumidity(tempC, humidity) { const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3)) return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000 } function uvLabel(uv) { if (uv < 3) return 'Low' if (uv < 6) return 'Moderate' if (uv < 8) return 'High' if (uv < 11) return 'Very High' return 'Extreme' } function cloudsLabel(fraction) { if (fraction < 0.1) return 'Clear' if (fraction < 0.3) return 'Mostly Clear' if (fraction < 0.6) return 'Partly Cloudy' if (fraction < 0.9) return 'Mostly Cloudy' return 'Overcast' } // ── Daily ───────────────────────────────────────────────────────────────────── const DAILY_FIELDS = [ 'temperature_2m_max', 'temperature_2m_min', 'precipitation_sum', 'precipitation_probability_max', 'windspeed_10m_max', 'windgusts_10m_max', 'winddirection_10m_dominant', 'weathercode', 'sunrise', 'sunset', 'relative_humidity_2m_mean', 'uv_index_max', 'shortwave_radiation_sum', ].join(',') export async function dailyWeather(lat, lon, start, end) { const startStr = start.toISOString().slice(0, 10) const endStr = end.toISOString().slice(0, 10) const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` + `&daily=${DAILY_FIELDS}&timezone=auto&start_date=${startStr}&end_date=${endStr}` const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url) if (!data?.daily?.time) return [] return Promise.all(data.daily.time.map(async (time, i) => { const code = data.daily.weathercode[i] const icon = await ensureIcon(code).catch(() => null) const temp = data.daily.temperature_2m_max[i] const humidity = data.daily.relative_humidity_2m_mean[i] const uv = data.daily.uv_index_max[i] // "2026-03-01" → local midnight by shifting from UTC midnight by tz offset const utcMidnight = new Date(`${time}T00:00:00Z`) const offset = tzOffsetMinutes(utcMidnight) const localMidnight = new Date(utcMidnight.getTime() - offset * 60_000) return { label: WMO[code] || 'Unknown', icon, code, time: localMidnight, temperature: temp, temperature_max: temp, temperature_min: data.daily.temperature_2m_min[i], humidity, humidity_abs: absoluteHumidity(temp, humidity), dew_point: dewPoint(temp, humidity), heat_index: heatIndex(temp, humidity), vapor_pressure_deficit: vaporPressureDeficit(temp, humidity), precipitation: data.daily.precipitation_sum[i], precipitation_chance: data.daily.precipitation_probability_max[i], wind_speed: data.daily.windspeed_10m_max[i], wind_gusts: data.daily.windgusts_10m_max[i], wind_direction: data.daily.winddirection_10m_dominant[i], uv_index: uv, uv_index_label: uvLabel(uv), solar_wm2: data.daily.shortwave_radiation_sum[i], clouds: null, sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null, sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null, } })) } // ── Hourly ──────────────────────────────────────────────────────────────────── const HOURLY_FIELDS = [ 'temperature_2m', 'relative_humidity_2m', 'dewpoint_2m', 'apparent_temperature', 'precipitation_probability', 'precipitation', 'weathercode', 'surface_pressure', 'cloudcover', 'windspeed_10m', 'windgusts_10m', 'winddirection_10m', 'uv_index', 'shortwave_radiation', 'vapour_pressure_deficit', 'visibility', ].join(',') export async function hourlyWeather(lat, lon, start, end) { const startStr = start instanceof Date ? start.toISOString().slice(0, 10) : start.slice(0, 10) const endStr = end instanceof Date ? end.toISOString().slice(0, 10) : end.slice(0, 10) const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` + `&hourly=${HOURLY_FIELDS}&daily=sunrise,sunset&timezone=auto&start_date=${startStr}&end_date=${endStr}` const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url) if (!data?.hourly?.time) return [] // Open-Meteo returns local time strings e.g. "2026-03-01T06:00" (no Z) // sunMap keyed by date portion of those local strings const sunMap = {} if (data.daily?.time) { data.daily.time.forEach((date, i) => { sunMap[date] = { sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null, sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null, } }) } return Promise.all(data.hourly.time.map(async (time, i) => { const dateKey = time.slice(0, 10) // "2026-03-01" from local string const sun = sunMap[dateKey] ?? {} // Open-Meteo local strings have no Z — append Z to treat as UTC for ms comparison // This works because Open-Meteo already returns times in the location's local tz // and sunrise/sunset are parsed the same way, so the comparison is consistent const t = new Date(time + 'Z').getTime() const daytime = sun.sunrise && sun.sunset ? t >= new Date(sun.sunrise.toISOString().replace('Z', '') + 'Z').getTime() && t < new Date(sun.sunset.toISOString().replace('Z', '') + 'Z').getTime() : false const code = data.hourly.weathercode[i] const icon = await ensureIcon(code).catch(() => null) const temp = data.hourly.temperature_2m[i] const humidity = data.hourly.relative_humidity_2m[i] const uv = data.hourly.uv_index[i] const clouds = Math.round(data.hourly.cloudcover[i]) / 100 return { time: new Date(time + 'Z'), daytime, label: WMO[code] || 'Unknown', icon, code, temperature: temp, humidity, humidity_abs: absoluteHumidity(temp, humidity), dew_point: dewPoint(temp, humidity), heat_index: heatIndex(temp, humidity), vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i] ?? vaporPressureDeficit(temp, humidity), pressure_hpa: data.hourly.surface_pressure[i], precipitation: data.hourly.precipitation[i], precipitation_chance: data.hourly.precipitation_probability[i], wind_speed: data.hourly.windspeed_10m[i], wind_gusts: data.hourly.windgusts_10m[i], wind_direction: data.hourly.winddirection_10m[i], uv_index: uv, uv_index_label: uvLabel(uv), solar_wm2: data.hourly.shortwave_radiation[i], clouds_label: cloudsLabel(clouds), visibility: data.hourly.visibility[i] != null ? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10 : null, } })) }