sdp-request-watcher/src/weather.js
2026-10-15 15:23:05 -04:00

108 lines
3.8 KiB
JavaScript

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
};