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) })); } function createWeatherService({ apiKey, zipCode }) { const enabled = !!(apiKey && zipCode); // US zip by default; "12345,ca" style values pass through untouched const zip = zipCode && zipCode.includes(',') ? zipCode : `${zipCode},us`; let cache = null; let fetchedAt = 0; let pending = null; let lastError = null; async function fetchWeather() { 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() { if (!enabled) return { enabled: false }; if (Date.now() - fetchedAt > CACHE_MINUTES * 60 * 1000) { // share one request between callers that arrive while it's in flight pending = pending || fetchWeather() .then(data => { cache = data; lastError = null; }) .catch(err => { lastError = err.response?.data?.message || err.message; console.error('Weather error: ', lastError); }) .finally(() => { fetchedAt = Date.now(); pending = null; }); await pending; } return { enabled: true, error: lastError, ...cache }; } return { getWeather }; } module.exports = { createWeatherService };