const axios = require('axios'); const API_URL = 'https://api.openweathermap.org/data/2.5'; const CACHE_MINUTES = 10; // OpenWeatherMap only updates about every 10 minutes anyway const FORECAST_DAYS = 5; // only pass through icon codes like "10d" so the page can build the image URL safely function iconCode(icon) { return /^\d\d[dn]$/.test(icon || '') ? icon : null; } // the 5 day forecast comes in 3 hour slots; roll them up into one entry per local day function dailyForecast(forecast) { const offset = (forecast.city?.timezone || 0) * 1000; const days = new Map(); for (const slot of forecast.list || []) { const local = new Date(slot.dt * 1000 + offset); const key = local.toISOString().slice(0, 10); if (!days.has(key)) days.set(key, { date: key, low: Infinity, high: -Infinity, pop: 0, noon: null }); const day = days.get(key); day.low = Math.min(day.low, slot.main.temp_min); day.high = Math.max(day.high, slot.main.temp_max); day.pop = Math.max(day.pop, slot.pop || 0); // the slot nearest midday best represents the day's conditions const distance = Math.abs(local.getUTCHours() - 12); if (!day.noon || distance < day.noon.distance) day.noon = { distance, weather: slot.weather?.[0] }; } return [...days.values()].slice(0, FORECAST_DAYS).map(day => ({ date: day.date, low: Math.round(day.low), high: Math.round(day.high), pop: Math.round(day.pop * 100), condition: day.noon?.weather?.main || null, icon: iconCode(day.noon?.weather?.icon) })); } // one cache entry per zip; zips come from the fixed list in places.js, so this stays small function createWeatherService({ apiKey }) { const enabled = !!apiKey; const cache = new Map(); async function fetchWeather(zipCode) { // US zip by default; "12345,ca" style values pass through untouched const zip = zipCode.includes(',') ? zipCode : `${zipCode},us`; const params = { zip, units: 'imperial', appid: apiKey }; const [current, forecast] = await Promise.all([ axios.get(`${API_URL}/weather`, { params }), axios.get(`${API_URL}/forecast`, { params }) ]); const c = current.data; return { place: c.name, updatedAt: c.dt * 1000, temp: Math.round(c.main.temp), feelsLike: Math.round(c.main.feels_like), high: Math.round(c.main.temp_max), low: Math.round(c.main.temp_min), humidity: c.main.humidity, wind: Math.round(c.wind?.speed || 0), condition: c.weather?.[0]?.main || null, description: c.weather?.[0]?.description || null, icon: iconCode(c.weather?.[0]?.icon), sunrise: c.sys?.sunrise ? c.sys.sunrise * 1000 : null, sunset: c.sys?.sunset ? c.sys.sunset * 1000 : null, forecast: dailyForecast(forecast.data) }; } async function getWeather(zipCode) { if (!enabled || !zipCode) return { enabled: false }; if (!cache.has(zipCode)) cache.set(zipCode, { data: null, fetchedAt: 0, pending: null, lastError: null }); const entry = cache.get(zipCode); if (Date.now() - entry.fetchedAt > CACHE_MINUTES * 60 * 1000) { // share one request between callers that arrive while it's in flight entry.pending = entry.pending || fetchWeather(zipCode) .then(data => { entry.data = data; entry.lastError = null; }) .catch(err => { entry.lastError = err.response?.data?.message || err.message; console.error(`Weather error (${zipCode}): `, entry.lastError); }) .finally(() => { entry.fetchedAt = Date.now(); entry.pending = null; }); await entry.pending; } return { enabled: true, error: entry.lastError, ...entry.data }; } return { enabled, getWeather }; } module.exports = { createWeatherService };