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>
This commit is contained in:
Skylar Grant 2026-10-15 21:04:22 -04:00
parent 1a9de77dc6
commit 9479b122fa
8 changed files with 422 additions and 60 deletions

63
src/names.js Normal file
View File

@ -0,0 +1,63 @@
const path = require('path');
const { createJsonFile } = require('./store');
const { fetchRecentNames } = require('./tickets');
const REFRESH_HOURS = 1; // SDP is asked for fresh names at most this often, whoever asks
const LOOKBACK_DAYS = 30;
const PAGES = 5; // 500 tickets is plenty to find the active technicians and sites
const MAX_NAMES = 500; // per list, so the file can't grow without end
const FIELDS = ['sites', 'groups', 'technicians'];
// Names seen in SDP, shared by all users and kept across restarts, so suggestions build up
// over time. Typing a name by hand always works; this only saves people from typos.
function createNameCache({ dataDir, seed }) {
const file = createJsonFile(path.join(dataDir, 'names.json'), { sites: [], groups: [], technicians: [], fetchedAt: 0 });
let pending = null;
function merge(found) {
const value = file.get();
for (const field of FIELDS) {
const byLower = new Map(value[field].map(n => [n.toLowerCase(), n]));
for (const name of found[field] || []) {
if (!byLower.has(name.toLowerCase())) byLower.set(name.toLowerCase(), name);
}
value[field] = [...byLower.values()].sort((a, b) => a.localeCompare(b)).slice(0, MAX_NAMES);
}
}
// the user's token is used to refresh; if SDP says no, the cached names are still returned
async function list(accessToken) {
if (Date.now() - file.get().fetchedAt > REFRESH_HOURS * 60 * 60 * 1000) {
pending = pending || fetchRecentNames({ accessToken, days: LOOKBACK_DAYS, pages: PAGES })
.then(found => {
merge(found);
file.get().fetchedAt = Date.now();
return file.save();
})
.catch(err => {
if (err.response?.status === 401) throw err;
console.error('Could not refresh name suggestions:', err.response?.data || err.message);
})
.finally(() => { pending = null; });
await pending;
}
const value = file.get();
merge(seed());
return Object.fromEntries(FIELDS.map(f => [f, value[f]]));
}
// a typed name that differs from SDP only in case is stored with SDP's spelling, since the
// technician filters compare exactly
function canonical(field, names) {
const byLower = new Map(file.get()[field].map(n => [n.toLowerCase(), n]));
return names.map(n => byLower.get(n.toLowerCase()) || n);
}
return { list, canonical };
}
module.exports = {
createNameCache
};

16
src/places.js Normal file
View File

@ -0,0 +1,16 @@
// Weather locations people can pick from in settings. Keeping it to a fixed list means the
// browser can't make the server look up arbitrary places, and the weather cache stays small.
// Add satellites here as needed; zip is a US zip code.
module.exports = [
{ zip: '04330', label: 'MCCS System Office (Augusta)' },
{ zip: '04210', label: 'CMCC (Auburn)' },
{ zip: '04543', label: 'CMCC Damariscotta' },
{ zip: '04401', label: 'EMCC (Bangor)' },
{ zip: '04937', label: 'KVCC (Fairfield)' },
{ zip: '04944', label: 'KVCC Alfond Campus (Hinckley)' },
{ zip: '04769', label: 'NMCC (Presque Isle)' },
{ zip: '04106', label: 'SMCC (South Portland)' },
{ zip: '04011', label: 'SMCC Midcoast (Brunswick)' },
{ zip: '04619', label: 'WCCC (Calais)' },
{ zip: '04090', label: 'YCCC (Wells)' }
];

View File

@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets'); const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
const { LIMITS, PANELS, SettingsError } = require('./settings');
// Sign-in: Zoho's web OAuth flow. Every user approves with their own Zoho account. // Sign-in: Zoho's web OAuth flow. Every user approves with their own Zoho account.
function createAuthRoutes({ zoho, sessions }) { function createAuthRoutes({ zoho, sessions }) {
@ -45,9 +46,10 @@ function createAuthRoutes({ zoho, sessions }) {
return router; return router;
} }
// Data: each call runs against SDP with the caller's own token. Nothing is cached per user, // Data: each call runs against SDP with the caller's own token and their saved settings.
// so the browser decides how often to ask (see checkMinutes and workloadMinutes). // Nothing is cached per user, so the browser decides how often to ask (see checkMinutes and
function createApiRoutes({ sessions, weatherService, settings, timing }) { // the user's workloadMinutes).
function createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }) {
const router = express.Router(); const router = express.Router();
router.use((req, res, next) => { router.use((req, res, next) => {
@ -67,26 +69,67 @@ function createApiRoutes({ sessions, weatherService, settings, timing }) {
} }
}; };
// everything the settings panel needs, sent with the session and after each save
const settingsReply = user => ({
settings: userSettings.forUser(user),
settingsSaved: userSettings.hasSaved(user),
canSaveSettings: userSettings.canSave(user)
});
router.get('/session', (req, res) => { router.get('/session', (req, res) => {
res.json({ res.json({
user: req.session.user, user: req.session.user,
sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000, sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000,
// the region filters every query uses; a future settings panel would start from these ...settingsReply(req.session.user),
settings: { settingsLimits: { ...LIMITS, panels: PANELS },
sites: settings.scope.sites, places: weatherService.enabled ? places : [],
groups: settings.scope.groups,
technicians: settings.technicians
},
checkMinutes: timing.checkMinutes, checkMinutes: timing.checkMinutes,
windowMinutes: timing.windowMinutes, windowMinutes: timing.windowMinutes
workloadMinutes: timing.workloadMinutes,
feedHours: timing.feedHours
}); });
}); });
// only JSON bodies; a cross-site form can't send one without a CORS preflight we never answer
const readJson = express.json({ limit: '32kb' });
const jsonBody = (req, res, next) => readJson(req, res, err => {
if (err) return res.status(400).json({ error: 'Settings were not valid JSON' });
next();
});
router.put('/settings', jsonBody, async (req, res) => {
if (!req.is('application/json')) return res.status(415).json({ error: 'Send settings as JSON' });
const input = { ...req.body };
for (const field of ['sites', 'groups', 'technicians']) {
if (Array.isArray(input[field])) input[field] = nameCache.canonical(field, input[field]);
}
try {
await userSettings.save(req.session.user, input);
} catch (err) {
if (err instanceof SettingsError) return res.status(400).json({ error: err.message });
console.error('Could not save settings:', err.message);
return res.status(500).json({ error: 'The server could not save your settings' });
}
res.json(settingsReply(req.session.user));
});
router.delete('/settings', async (req, res) => {
try {
await userSettings.reset(req.session.user);
} catch (err) {
console.error('Could not reset settings:', err.message);
return res.status(500).json({ error: 'The server could not reset your settings' });
}
res.json(settingsReply(req.session.user));
});
// suggestions for the settings panel; typing any name still works
router.get('/names', signedOutOnSdp401(async (req, res) => {
res.json(await nameCache.list(req.session.accessToken));
}));
router.get('/closed', signedOutOnSdp401(async (req, res) => { router.get('/closed', signedOutOnSdp401(async (req, res) => {
const prefs = userSettings.forUser(req.session.user);
// the page asks for the full feed on first load, then a short overlapping window each check // the page asks for the full feed on first load, then a short overlapping window each check
const minutes = Math.min(Math.max(Number(req.query.minutes) || timing.windowMinutes, 1), timing.feedHours * 60); const minutes = Math.min(Math.max(Number(req.query.minutes) || timing.windowMinutes, 1), prefs.feedHours * 60);
const state = { lastCheck: null, lastError: null }; const state = { lastCheck: null, lastError: null };
const tickets = new Map(); const tickets = new Map();
@ -94,9 +137,9 @@ function createApiRoutes({ sessions, weatherService, settings, timing }) {
accessToken: req.session.accessToken, accessToken: req.session.accessToken,
state, state,
tickets, tickets,
technicians: settings.technicians, technicians: prefs.technicians,
statusSet: settings.statusSet, statusSet: settings.statusSet,
scope: settings.scope, scope: { sites: prefs.sites, groups: prefs.groups },
windowMinutes: minutes windowMinutes: minutes
}); });
@ -108,28 +151,30 @@ function createApiRoutes({ sessions, weatherService, settings, timing }) {
})); }));
router.get('/workload', signedOutOnSdp401(async (req, res) => { router.get('/workload', signedOutOnSdp401(async (req, res) => {
const prefs = userSettings.forUser(req.session.user);
const scope = { sites: prefs.sites, groups: prefs.groups };
const workload = { updatedAt: null, error: null, truncated: false, statuses: [], technicians: [] }; const workload = { updatedAt: null, error: null, truncated: false, statuses: [], technicians: [] };
const unassigned = { updatedAt: null, error: null, truncated: false, count: 0, discarded: 0, tickets: [] }; const unassigned = { updatedAt: null, error: null, truncated: false, count: 0, discarded: 0, tickets: [] };
await fetchWorkload({ await fetchWorkload({
accessToken: req.session.accessToken, accessToken: req.session.accessToken,
workload, workload,
technicians: settings.technicians, technicians: prefs.technicians,
closedStatuses: settings.closedStatuses, closedStatuses: settings.closedStatuses,
scope: settings.scope scope
}); });
await fetchUnassigned({ await fetchUnassigned({
accessToken: req.session.accessToken, accessToken: req.session.accessToken,
unassigned, unassigned,
excludedStatuses: settings.unassignedExcludedStatuses, excludedStatuses: settings.unassignedExcludedStatuses,
scope: settings.scope scope
}); });
res.json({ workload, unassigned }); res.json({ workload, unassigned });
})); }));
router.get('/weather', async (req, res) => { router.get('/weather', async (req, res) => {
res.json(await weatherService.getWeather()); res.json(await weatherService.getWeather(userSettings.forUser(req.session.user).weatherZip));
}); });
return router; return router;

View File

@ -9,6 +9,9 @@ const { createZohoAuth } = require('./auth');
const { createSessions } = require('./session'); const { createSessions } = require('./session');
const { createAuthRoutes, createApiRoutes } = require('./routes'); const { createAuthRoutes, createApiRoutes } = require('./routes');
const { createWeatherService } = require('./weather'); const { createWeatherService } = require('./weather');
const { createUserSettings } = require('./settings');
const { createNameCache } = require('./names');
const places = require('./places');
const { CLIENT_ID, CLIENT_SECRET, SESSION_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env; const { CLIENT_ID, CLIENT_SECRET, SESSION_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env;
const PORT = process.env.PORT || 3000; // default 3000 const PORT = process.env.PORT || 3000; // default 3000
@ -31,25 +34,19 @@ const statuses = toList(STATUS_NAMES);
// statuses that count as done; everything else shows up in the workload table // statuses that count as done; everything else shows up in the workload table
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled']; const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
// the region, applied to every query for every user. Kept server side for now; per-browser // the status rules every user shares; these aren't in the settings panel
// settings (see docs/ROADMAP.md) would override these within limits the server checks.
const settings = { const settings = {
technicians,
statusSet: new Set(statuses.map(s => s.toLowerCase())), statusSet: new Set(statuses.map(s => s.toLowerCase())),
closedStatuses, closedStatuses,
// statuses the unassigned counter ignores, to match the ManageEngine view // statuses the unassigned counter ignores, to match the ManageEngine view
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES) ? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
: closedStatuses, : closedStatuses
// restricts every query to these sites/groups; empty means no restriction
scope: { sites: toList(SITE_NAMES), groups: toList(GROUP_NAMES) }
}; };
const timing = { const timing = {
windowMinutes: 10, // how far back each closed check looks, in minutes windowMinutes: 10, // how far back each closed check looks, in minutes
checkMinutes: 1, // how often the page checks for newly closed tickets, in minutes checkMinutes: 1, // how often the page checks for newly closed tickets, in minutes
workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table, in minutes
feedHours: 12, // how long a closed ticket stays in the feed
sessionDays: Number(process.env.SESSION_DAYS) || 7 // how long a sign-in lasts before Zoho is asked again sessionDays: Number(process.env.SESSION_DAYS) || 7 // how long a sign-in lasts before Zoho is asked again
}; };
@ -67,8 +64,40 @@ const sessions = createSessions({
zoho zoho
}); });
// weather widget is optional; it stays hidden unless both values are set // weather widget is optional; it stays hidden without an API key
const weatherService = createWeatherService({ apiKey: OWM_API_KEY, zipCode: OWM_ZIP_CODE }); const weatherService = createWeatherService({ apiKey: OWM_API_KEY });
// the .env zip is the default location; make sure it's on the list people pick from
if (OWM_ZIP_CODE && !places.some(p => p.zip === OWM_ZIP_CODE)) {
places.unshift({ zip: OWM_ZIP_CODE, label: `Default (${OWM_ZIP_CODE})` });
}
// what each user starts with; the settings panel overrides these per user
const defaults = {
sites: toList(SITE_NAMES), // empty means every site
groups: toList(GROUP_NAMES), // empty means every group
technicians, // empty means everyone, plus an Unassigned row
workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table
feedHours: 12, // how long a closed ticket stays in the feed
weatherZip: OWM_ZIP_CODE || null,
hiddenStatuses: [], // status columns left out of the table
hiddenPanels: [] // see PANELS in settings.js
};
// settings and name suggestions are the only things the server keeps; mount a volume here in docker
const dataDir = path.resolve(process.env.DATA_DIR || path.join(__dirname, '..', 'data'));
const userSettings = createUserSettings({ dataDir, defaults, places });
const nameCache = createNameCache({
dataDir,
seed: () => {
const saved = userSettings.allNames();
return {
sites: [...defaults.sites, ...saved.sites],
groups: [...defaults.groups, ...saved.groups],
technicians: [...defaults.technicians, ...saved.technicians]
};
}
});
const app = express(); const app = express();
app.disable('x-powered-by'); app.disable('x-powered-by');
@ -90,9 +119,9 @@ app.get('/healthz', (req, res) => res.send('ok'));
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.use('/auth', createAuthRoutes({ zoho, sessions })); app.use('/auth', createAuthRoutes({ zoho, sessions }));
app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing })); app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }));
//starts listening on port specified //starts listening on port specified
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}`); console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}, data in ${dataDir}`);
}); });

143
src/settings.js Normal file
View File

@ -0,0 +1,143 @@
const path = require('path');
const { createJsonFile } = require('./store');
// What the server accepts from the settings panel. These protect SDP and the server, not
// access: every query runs with the user's own token, so no setting can show more than SDP
// already lets them see.
const LIMITS = {
sites: 50,
groups: 50,
technicians: 100,
hiddenStatuses: 50,
nameLength: 100,
workloadMinutes: { min: 2, max: 60 },
feedHours: { min: 1, max: 24 }
};
// sections of the page that can be switched off; the closed watcher and its ding keep
// running when the feed is hidden
const PANELS = ['stats', 'weather', 'feed'];
const LIST_FIELDS = ['sites', 'groups', 'technicians', 'hiddenStatuses'];
class SettingsError extends Error {}
// trims, collapses spaces and drops case-insensitive repeats; rejects anything that isn't a plain name
function cleanList(value, field) {
if (!Array.isArray(value)) throw new SettingsError(`${field} must be a list`);
const seen = new Set();
const out = [];
for (const item of value) {
if (typeof item !== 'string') throw new SettingsError(`${field} must be a list of names`);
const name = item.replace(/\s+/g, ' ').trim();
if (!name) continue;
if (name.length > LIMITS.nameLength) throw new SettingsError(`"${name.slice(0, 40)}…" is too long`);
if (/[\u0000-\u001f\u007f]/.test(name)) throw new SettingsError(`${field} has an invalid name`);
if (seen.has(name.toLowerCase())) continue;
seen.add(name.toLowerCase());
out.push(name);
}
if (out.length > LIMITS[field]) throw new SettingsError(`At most ${LIMITS[field]} ${field}`);
return out;
}
function cleanNumber(value, field) {
const n = Number(value);
const { min, max } = LIMITS[field];
if (!Number.isInteger(n) || n < min || n > max) throw new SettingsError(`${field} must be a whole number from ${min} to ${max}`);
return n;
}
const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// Per-user settings, kept on the server and keyed by the email Zoho gave at sign-in, so they
// follow a person between browsers. Only fields that differ from the .env defaults are stored,
// so a user who never touched the region picks up later changes to .env.
function createUserSettings({ dataDir, defaults, places }) {
const file = createJsonFile(path.join(dataDir, 'settings.json'), { users: {} });
const zips = new Set(places.map(p => p.zip));
const keyFor = user => (user?.email ? user.email.toLowerCase() : null);
function validate(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new SettingsError('Settings must be an object');
const clean = {};
for (const field of LIST_FIELDS) {
if (input[field] !== undefined) clean[field] = cleanList(input[field], field);
}
for (const field of ['workloadMinutes', 'feedHours']) {
if (input[field] !== undefined) clean[field] = cleanNumber(input[field], field);
}
if (input.weatherZip !== undefined) {
if (!zips.has(input.weatherZip)) throw new SettingsError('Pick a weather location from the list');
clean.weatherZip = input.weatherZip;
}
if (input.hiddenPanels !== undefined) {
if (!Array.isArray(input.hiddenPanels) || input.hiddenPanels.some(p => !PANELS.includes(p))) {
throw new SettingsError(`hiddenPanels can only contain ${PANELS.join(', ')}`);
}
clean.hiddenPanels = PANELS.filter(p => input.hiddenPanels.includes(p));
}
return clean;
}
function stored(user) {
const key = keyFor(user);
return key ? file.get().users[key] : undefined;
}
// the defaults with this user's changes on top
function forUser(user) {
const record = stored(user);
const prefs = { ...defaults, ...(record?.settings || {}) };
// a location dropped from places.js falls back to the default rather than breaking weather
if (!zips.has(prefs.weatherZip)) prefs.weatherZip = defaults.weatherZip;
return prefs;
}
async function save(user, input) {
const key = keyFor(user);
if (!key) throw new SettingsError('Zoho didn\'t share your email at sign-in, so settings can\'t be saved. Sign out and in again.');
const clean = validate(input);
const overrides = {};
for (const [field, value] of Object.entries(clean)) {
if (!sameValue(value, defaults[field])) overrides[field] = value;
}
file.get().users[key] = { settings: overrides, updatedAt: Date.now() };
await file.save();
return forUser(user);
}
async function reset(user) {
const key = keyFor(user);
if (!key || !file.get().users[key]) return forUser(user);
// keep an empty record so the first-run prompt doesn't come back
file.get().users[key] = { settings: {}, updatedAt: Date.now() };
await file.save();
return forUser(user);
}
return {
forUser,
// false until the user has saved once; the page shows the setup prompt until then
hasSaved: user => !!stored(user),
canSave: user => !!keyFor(user),
save,
reset,
// every name anyone has saved, to help the suggestions along
allNames: () => {
const names = { sites: [], groups: [], technicians: [] };
for (const { settings } of Object.values(file.get().users)) {
for (const field of Object.keys(names)) names[field].push(...(settings[field] || []));
}
return names;
}
};
}
module.exports = {
LIMITS,
PANELS,
SettingsError,
createUserSettings
};

38
src/store.js Normal file
View File

@ -0,0 +1,38 @@
const fs = require('fs');
const path = require('path');
// A small JSON file the server keeps in memory and writes back on change. Enough for a few
// dozen users' settings; one process owns the file, so there's no locking beyond queuing writes.
function createJsonFile(file, initial) {
let value = initial;
try {
value = { ...initial, ...JSON.parse(fs.readFileSync(file, 'utf8')) };
} catch (err) {
if (err.code !== 'ENOENT') console.error(`Could not read ${file}, starting empty:`, err.message);
}
let queue = Promise.resolve();
// write to a temp file and rename, so a crash mid-write never leaves half a file
function save() {
const json = JSON.stringify(value, null, 2);
const tmp = `${file}.tmp`;
const write = queue.then(async () => {
await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(tmp, json);
await fs.promises.rename(tmp, file);
});
// a failed write shouldn't block the ones after it
queue = write.catch(() => {});
return write;
}
return {
get: () => value,
save
};
}
module.exports = {
createJsonFile
};

View File

@ -165,11 +165,6 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
}); });
} }
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
for (const [key, ticket] of tickets) {
if (ticket.closedAt < cutoff) tickets.delete(key);
}
state.lastCheck = Date.now(); state.lastCheck = Date.now();
state.lastError = null; state.lastError = null;
} catch (err) { } catch (err) {
@ -317,8 +312,41 @@ async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scop
} }
} }
// Site, group and technician names from recently updated tickets anywhere (no region filter),
// for the settings panel's suggestions. Only names come back, never ticket details.
async function fetchRecentNames({ accessToken, days, pages }) {
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const names = { sites: new Set(), groups: new Set(), technicians: new Set() };
// its own loop rather than getAllRequests: there will always be more rows than we want,
// which getAllRequests would read as SDP ignoring the criteria
for (let page = 0; page < pages; page++) {
const data = await getRequests(accessToken, {
sort_field: 'last_updated_time',
sort_order: 'desc',
row_count: PAGE_SIZE,
start_index: page * PAGE_SIZE + 1,
fields_required: ['display_id', 'technician', 'site', 'group'],
search_criteria: { field: 'last_updated_time', condition: 'greater than', value: since.toString() }
});
for (const t of data.requests || []) {
if (t.site?.name) names.sites.add(t.site.name);
if (t.group?.name) names.groups.add(t.group.name);
if (t.technician?.name) names.technicians.add(t.technician.name);
}
if (!data.list_info?.has_more_rows) break;
}
return {
sites: [...names.sites],
groups: [...names.groups],
technicians: [...names.technicians]
};
}
module.exports = { module.exports = {
fetchClosedTickets, fetchClosedTickets,
fetchWorkload, fetchWorkload,
fetchUnassigned fetchUnassigned,
fetchRecentNames
}; };

View File

@ -39,17 +39,14 @@ function dailyForecast(forecast) {
})); }));
} }
function createWeatherService({ apiKey, zipCode }) { // one cache entry per zip; zips come from the fixed list in places.js, so this stays small
const enabled = !!(apiKey && zipCode); 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 // US zip by default; "12345,ca" style values pass through untouched
const zip = zipCode && zipCode.includes(',') ? zipCode : `${zipCode},us`; const zip = 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 params = { zip, units: 'imperial', appid: apiKey };
const [current, forecast] = await Promise.all([ const [current, forecast] = await Promise.all([
axios.get(`${API_URL}/weather`, { params }), axios.get(`${API_URL}/weather`, { params }),
@ -75,31 +72,34 @@ function createWeatherService({ apiKey, zipCode }) {
}; };
} }
async function getWeather() { async function getWeather(zipCode) {
if (!enabled) return { enabled: false }; if (!enabled || !zipCode) return { enabled: false };
if (Date.now() - fetchedAt > CACHE_MINUTES * 60 * 1000) { 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 // share one request between callers that arrive while it's in flight
pending = pending || fetchWeather() entry.pending = entry.pending || fetchWeather(zipCode)
.then(data => { .then(data => {
cache = data; entry.data = data;
lastError = null; entry.lastError = null;
}) })
.catch(err => { .catch(err => {
lastError = err.response?.data?.message || err.message; entry.lastError = err.response?.data?.message || err.message;
console.error('Weather error: ', lastError); console.error(`Weather error (${zipCode}): `, entry.lastError);
}) })
.finally(() => { .finally(() => {
fetchedAt = Date.now(); entry.fetchedAt = Date.now();
pending = null; entry.pending = null;
}); });
await pending; await entry.pending;
} }
return { enabled: true, error: lastError, ...cache }; return { enabled: true, error: entry.lastError, ...entry.data };
} }
return { getWeather }; return { enabled, getWeather };
} }
module.exports = { module.exports = {