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