sdp-request-watcher/src/weather.js
Skylar Grant 9479b122fa Save per-user settings on the server
Each user's sites, groups, technicians, timing, weather location and
hidden panels/columns are saved by Zoho email in DATA_DIR, merged over
the .env defaults and validated on PUT /api/settings. /api/names
suggests names from recent tickets, and weather is cached per zip from a
fixed list of campuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:04:22 -04:00

108 lines
4.1 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)
}));
}
// 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
};