Initial commit: Service Times module

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 18:45:41 -04:00
co-authored by Claude Opus 5.5
commit b0db58268e
6 changed files with 2172 additions and 0 deletions
+570
View File
@@ -0,0 +1,570 @@
'use strict';
/**
* Service Times — server part.
*
* Owns the one connection to the church website, keeps every campus's Sunday
* service times in memory, and fans them — together with the production marks
* an operator puts on a service — out to every tile over SSE. Browsers never
* talk to the website.
*
* GET /state → { state } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
* POST /mark → mark one service (replay / do not use / issue)
* POST /clear → drop every mark (a fresh week)
* POST /refresh → re-read the website now
* GET /campuses → { options } — the campuses in play, for the admin
* broadcast picker and a tile's campus list
* GET /discovered → { options } — every campus page the website publishes,
* for the admin "Campuses" picker
*
* Why the marks live here and not in a tile's settings: a mark is the booth's
* shared answer to "is this service usable?", and every campus watching the
* board has to see the same one. Per-tile settings are per browser, so they
* would let two people disagree about whether the 9:30 is going out. Marks
* are written to the ProdDash data directory so a restart mid-morning does
* not lose them, and they expire on their own (admin "Marks") so last week's
* never greet a new Sunday.
*
* The website is the source of truth for times. Manual campuses (admin) are a
* stopgap for a campus the site has not published yet; the moment the site
* carries it, the site's times take over and the admin page says the manual
* line can go.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
parseCampusPage,
discoverCampusSlugs,
parseManualCampuses,
mergeCampuses,
parseFeeds,
assignRoles,
slugify,
} = require('./parse');
/** Abort a hung page read well before the refresh cadence. */
const REQUEST_TIMEOUT_MS = 12000;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** Fastest allowed re-read: service times change a few times a year. */
const MIN_REFRESH_MINUTES = 5;
/** Backoff after a failed read — keep the last good times on screen meanwhile. */
const RETRY_MS = 60000;
/** Guard against a redirect loop or a runaway campus list. */
const MAX_CAMPUSES = 40;
const BODY_LIMIT_BYTES = 64 * 1024;
const MARK_STATUSES = new Set(['ok', 'issue', 'replay', 'skip']);
const NOTE_LIMIT = 280;
/** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null;
/* ── small helpers ──────────────────────────────────────────────────── */
function sendJson(res, code, body) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
}
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let data = '';
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > BODY_LIMIT_BYTES) {
reject(new Error('Request body too large.'));
req.destroy();
return;
}
data += chunk;
});
req.on('end', () => {
if (!data.trim()) return resolve({});
try {
const body = JSON.parse(data);
resolve(body && typeof body === 'object' ? body : {});
} catch {
reject(new Error('Malformed JSON.'));
}
});
req.on('error', reject);
});
}
/** Route handlers are async — the shell only catches synchronous throws. */
function guard(fn) {
return (req, res) => {
Promise.resolve(fn(req, res)).catch((err) => {
try {
sendJson(res, err && err.status ? err.status : 500, { error: err?.message || String(err) });
} catch { /* the client is already gone */ }
});
};
}
/**
* Where ProdDash keeps per-machine state, resolved the way the shell does.
* Never the checkout: the repo folder is replaced on update.
*/
function resolveDataDir() {
const root = path.join(__dirname, '..', '..');
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
if (fromEnv) return path.resolve(root, fromEnv);
const home = os.homedir();
if (process.platform === 'win32') {
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
}
if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'ProdDash');
}
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
}
const MARKS_FILE = path.join(resolveDataDir(), 'service-times-marks.json');
function markKey(campus, service) {
return `${campus}::${service}`;
}
/* ── admin config shaping ───────────────────────────────────────────── */
function siteBase(config) {
const raw = String(config.siteUrl || '').trim().replace(/\/+$/, '');
if (!raw) return '';
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
}
function listSetting(value) {
if (Array.isArray(value)) return value.map((v) => slugify(v)).filter(Boolean);
if (typeof value === 'string') {
return value.split(/[\n,]+/).map((v) => slugify(v)).filter(Boolean);
}
return [];
}
/* ── the website ────────────────────────────────────────────────────── */
async function getText(url) {
const response = await fetch(url, {
redirect: 'follow',
headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
const err = new Error(`${response.status} ${response.statusText || ''}`.trim());
err.status = response.status;
throw err;
}
return response.text();
}
/** Every campus page the site publishes — the sitemap first, the index page as a fallback. */
async function discover(base, log) {
const sources = [];
for (const target of ['/page-sitemap.xml', '/wp-sitemap.xml', '/locations/', '/']) {
try {
sources.push(await getText(base + target));
if (target.includes('sitemap')) break; // a sitemap is the fuller list
} catch (err) {
log(`discovery: ${target} — ${err.message}`);
}
}
if (!sources.length) return [];
return discoverCampusSlugs(...sources).slice(0, MAX_CAMPUSES);
}
async function readCampus(base, slug) {
try {
const html = await getText(`${base}/locations/${slug}/`);
return parseCampusPage(html, { slug });
} catch (err) {
return {
slug,
name: slugify(slug).split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
services: [],
error: `Page unreadable: ${err.message}`,
};
}
}
/* ── module ─────────────────────────────────────────────────────────── */
module.exports = {
init({ config, log }) {
const streams = new Set();
const base = siteBase(config);
const chosen = listSetting(config.campuses);
const broadcast = new Set(listSetting(config.broadcast));
const { feeds, errors: feedErrors } = parseFeeds(config.feeds);
const { campuses: manualCampuses, errors: manualErrors } = parseManualCampuses(config.manual);
const retentionHours = Math.max(0, Number(config.retentionHours) || 0);
const refreshMs = Math.max(MIN_REFRESH_MINUTES, Number(config.refreshMinutes) || 360) * 60000;
let marks = new Map();
let marksWritable = true;
let discovered = [];
let siteCampuses = [];
let reachable = false;
let lastError = base ? '' : 'No website address configured';
let lastFetched = 0;
let timer = null;
let stopped = false;
let lastFrame = '';
/* ── marks: load, save, expire ──────────────────────────────────── */
function loadMarks() {
try {
const saved = JSON.parse(fs.readFileSync(MARKS_FILE, 'utf8'));
for (const [key, value] of Object.entries(saved?.marks || {})) {
if (value && typeof value === 'object') marks.set(key, value);
}
log(`${marks.size} mark(s) restored`);
} catch {
/* no marks yet — the usual case */
}
}
function saveMarks() {
const payload = { marks: Object.fromEntries(marks), savedAt: Date.now() };
try {
fs.mkdirSync(path.dirname(MARKS_FILE), { recursive: true });
const tmp = `${MARKS_FILE}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n');
fs.renameSync(tmp, MARKS_FILE);
marksWritable = true;
} catch (err) {
// Never fall back to writing inside the checkout: the marks stay in
// memory and health() says they will not survive a restart.
if (marksWritable) log(`marks cannot be saved (${err.message}) — keeping them in memory only`);
marksWritable = false;
}
}
/** Drop marks older than the retention window. Returns true if any went. */
function expireMarks() {
if (!retentionHours) return false;
const cutoff = Date.now() - retentionHours * 3600000;
let dropped = false;
for (const [key, mark] of marks) {
if (!(Number(mark?.at) > cutoff)) {
marks.delete(key);
dropped = true;
}
}
if (dropped) saveMarks();
return dropped;
}
/* ── the view every tile draws ──────────────────────────────────── */
function campusList() {
const wanted = chosen.length
? siteCampuses.filter((c) => chosen.includes(c.slug))
: siteCampuses;
const merged = assignRoles(mergeCampuses(wanted, manualCampuses), { broadcast, feeds });
// Marks ride along on the service they belong to.
for (const campus of merged) {
campus.services = campus.services.map((service) => {
const mark = marks.get(markKey(campus.slug, service.id)) || null;
return { ...service, mark };
});
}
return merged;
}
function snapshot() {
return {
configured: Boolean(base),
reachable,
lastError,
lastFetched,
siteUrl: base,
refreshMinutes: refreshMs / 60000,
retentionHours,
marksWritable,
campuses: campusList(),
discovered,
manualErrors,
feedErrors,
now: Date.now(),
};
}
function broadcastState({ force = false } = {}) {
if (!streams.size) return;
const view = snapshot();
let frame;
try {
frame = JSON.stringify(view);
} catch {
return;
}
// `now` changes every call; compare without it so idle tiles stay idle.
const compare = frame.replace(/,"now":\d+/, '');
if (!force && compare === lastFrame) return;
lastFrame = compare;
const payload = `event: state\ndata: ${frame}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
}
/* ── the refresh loop ───────────────────────────────────────────── */
async function refresh() {
if (stopped) return;
if (!base) {
reachable = false;
lastError = 'No website address configured';
return;
}
try {
const found = await discover(base, log);
if (found.length) discovered = found;
const slugs = chosen.length ? chosen : discovered;
if (!slugs.length) throw new Error('No campus pages found on the site');
const read = await Promise.all(slugs.slice(0, MAX_CAMPUSES).map((slug) => readCampus(base, slug)));
const usable = read.filter((campus) => campus.services.length);
// A total failure keeps the last good times on screen — a site outage
// must not blank the board in the middle of a service.
if (!usable.length && siteCampuses.length) {
throw new Error(read[0]?.error || 'No service times found on the site');
}
siteCampuses = read;
reachable = true;
lastFetched = Date.now();
const broken = read.filter((campus) => campus.error);
lastError = broken.length ? `${broken.length} campus page(s) unreadable: ${broken[0].error}` : '';
log(`read ${usable.length}/${read.length} campus page(s) from ${base}`);
} catch (err) {
reachable = false;
lastError = err?.message || String(err);
log(`refresh failed: ${lastError}`);
}
}
function schedule(delay) {
clearTimeout(timer);
if (stopped) return;
timer = setTimeout(async () => {
await refresh();
expireMarks();
broadcastState();
schedule(reachable ? refreshMs : RETRY_MS);
}, delay);
timer.unref?.();
}
loadMarks();
expireMarks();
if (base) {
log(`reading service times from ${base} every ${Math.round(refreshMs / 60000)} min`);
} else {
log('no website address configured — set one in /admin');
}
// First read immediately, then on the configured cadence.
schedule(0);
const heartbeat = setInterval(() => {
expireMarks();
broadcastState();
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
current = {
streams,
snapshot,
broadcastState,
refreshNow: async () => {
await refresh();
broadcastState({ force: true });
},
setMark(campusSlug, serviceId, patch) {
const key = markKey(slugify(campusSlug), String(serviceId || ''));
const status = MARK_STATUSES.has(patch.status) ? patch.status : 'ok';
if (status === 'ok' && !patch.note && !patch.source) {
marks.delete(key);
} else {
marks.set(key, {
status,
note: String(patch.note || '').slice(0, NOTE_LIMIT),
source: String(patch.source || '').slice(0, 120),
at: Date.now(),
});
}
saveMarks();
broadcastState({ force: true });
},
clearMarks() {
const had = marks.size;
marks.clear();
saveMarks();
broadcastState({ force: true });
return had;
},
};
return {
stop() {
stopped = true;
clearTimeout(timer);
clearInterval(heartbeat);
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (current && current.streams === streams) current = null;
},
health() {
if (!base) return { status: 'error', message: 'No website address configured' };
const notes = [];
if (!marksWritable) notes.push('marks are memory-only — the data directory is not writable');
if (manualErrors.length) notes.push(manualErrors[0]);
if (feedErrors.length) notes.push(feedErrors[0]);
const superseded = campusList().filter((c) => c.supersededManual).map((c) => c.name);
if (superseded.length) {
notes.push(`the website now publishes ${superseded.join(', ')} — the manual line(s) can be removed`);
}
const suffix = notes.length ? ` — ${notes.join('; ')}` : '';
if (!reachable) {
return { status: 'error', message: `${lastError || 'Website unreachable'}${suffix}` };
}
const count = campusList().reduce((sum, c) => sum + c.services.length, 0);
const warn = lastError || notes.length;
return {
status: warn ? 'connecting' : 'ok',
message: `${count} service(s) across ${campusList().length} campus(es)${lastError ? ` — ${lastError}` : ''}${suffix}`,
};
},
};
},
/**
* The board, plus one entry per campus. A campus booth wants its own times
* and the service it is taking resi from — not the whole network — so those
* entries fix the campus and drop the campus picker from their gear.
*/
tiles() {
const board = {
id: 'board',
name: 'Service Times',
description: 'Every campus, its services, and what is marked for replay or unusable',
defaultSize: { w: 6, h: 5 },
minSize: { w: 3, h: 3 },
settings: {},
};
const campuses = current ? current.snapshot().campuses : [];
return [
board,
...campuses.map((campus) => ({
id: campus.slug,
name: campus.name,
description: `${campus.name} service times`,
defaultSize: { w: 3, h: 4 },
minSize: { w: 2, h: 2 },
settings: { campuses: [campus.slug] },
instanceSchema: {
showDay: { type: 'boolean', label: 'Day', default: true, group: 'Show' },
showRole: { type: 'boolean', label: 'Role', default: true, group: 'Show' },
showSource: { type: 'boolean', label: 'Resi source', default: true, group: 'Show' },
showDependents: { type: 'boolean', label: 'Campuses fed', default: true, group: 'Show' },
showNotes: { type: 'boolean', label: 'Notes', default: true, group: 'Show' },
showUpdated: { type: 'boolean', label: 'Updated line', default: false, group: 'Show' },
},
instanceGroups: { Show: { collapsed: true, columns: 2 } },
})),
];
},
routes() {
return {
'GET /state': (req, res) => {
sendJson(res, 200, { state: current ? current.snapshot() : null });
},
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
res.write(`event: state\ndata: ${JSON.stringify(current ? current.snapshot() : null)}\n\n`);
if (!current) return void res.end();
const { streams } = current;
// A viewer that stops reading (locked tablet, sleeping laptop) must
// not queue frames forever; keepalive probes make the OS notice.
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
streams.add(res);
req.on('close', () => streams.delete(res));
},
'POST /mark': guard(async (req, res) => {
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
const body = await readJsonBody(req);
const campus = String(body.campus || '').trim();
const service = String(body.service || '').trim();
if (!campus || !service) return sendJson(res, 400, { error: 'campus and service are required' });
current.setMark(campus, service, {
status: body.status,
note: body.note,
source: body.source,
});
sendJson(res, 200, { ok: true });
}),
'POST /clear': guard(async (req, res) => {
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
sendJson(res, 200, { ok: true, cleared: current.clearMarks() });
}),
'POST /refresh': guard(async (req, res) => {
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
await current.refreshNow();
sendJson(res, 200, { ok: true, state: current.snapshot() });
}),
'GET /campuses': (req, res) => {
const campuses = current ? current.snapshot().campuses : [];
sendJson(res, 200, {
options: campuses.map((campus) => ({ value: campus.slug, label: campus.name })),
});
},
'GET /discovered': (req, res) => {
const state = current ? current.snapshot() : null;
const names = new Map((state?.campuses || []).map((c) => [c.slug, c.name]));
const slugs = state?.discovered?.length ? state.discovered : [...names.keys()];
sendJson(res, 200, {
options: slugs.map((slug) => ({
value: slug,
label: names.get(slug)
|| slug.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
})),
});
},
};
},
};