108 lines
3.8 KiB
JavaScript
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
|
|
};
|