Merge pull request 'Service Times 1.0.0: every campus's services, and what production can't use' (#20) from claude/service-times-module into main

This commit was merged in pull request #20.
This commit is contained in:
2026-09-24 11:34:38 -04:00
7 changed files with 2189 additions and 0 deletions
+17
View File
@@ -383,6 +383,23 @@ below. Nothing is installed without someone clicking.
order), default columns, and the photo folder. Tile settings: parallax,
names, mic labels, and birthday confetti with its intensity and a test
picker — columns are − / + in the title bar.
- **Service Times** — every campus's Sunday service times, read from the
church website and re-read on a timer, so the board follows the site when
the times change through the year. Any service can be marked for
production — issue, replay, or do not use — with a note, and a campus
taking resi records which of the origin's services it is playing. Marking
a broadcast service "do not use" flags every campus riding on it, which is
the question the board exists to answer. Marks are shared by every tile
and every browser, survive a restart, and expire on their own so last
week's never greet a new Sunday. The + picker offers the whole board or a
single campus.
Admin config is in four groups: **Website** (address, which campuses to
follow, refresh interval), **Broadcast** (the campuses the broadcast comes
from, and each other campus's resi source), **Manual campuses** (a stopgap
for a campus the site has not published yet — the website takes over as
soon as it does) and **Marks** (how long a mark lives).
Tile settings: campuses, card or list layout, and a **Show** group for the
day, role, resi source, campuses fed, notes and the updated line.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
+471
View File
@@ -0,0 +1,471 @@
/* Service Times — the board.
*
* One card per campus, one row per service, and a mark on any service that
* production has to treat differently this week. Everything is drawn from the
* server's SSE state so every booth sees the same marks at the same moment;
* nothing here talks to the website.
*
* Marking lives in the tile body, not the title bar, because the bar folds
* away in fullscreen and on an auto-hiding dashboard — a control an operator
* needs mid-service may not disappear with it.
*/
const STATUSES = [
{ value: 'ok', label: 'Clear', short: '' },
{ value: 'issue', label: 'Issue', short: 'Issue' },
{ value: 'replay', label: 'Replay', short: 'Replay' },
{ value: 'skip', label: 'Do not use', short: 'Do not use' },
];
const ICON_REFRESH = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
+ '<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>'
+ '<path d="M13.6 1.9v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const ICON_CLEAR = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
+ '<path d="M3 4h10M6.5 4V2.8h3V4M4.4 4l.7 8.4a1 1 0 0 0 1 .9h3.8a1 1 0 0 0 1-.9L11.6 4"'
+ ' fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const ICON_SMALLER = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
+ '<path d="M3.5 8h9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
const ICON_BIGGER = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
+ '<path d="M3.5 8h9M8 3.5v9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
const TEXT_MIN = 0.8;
const TEXT_MAX = 1.8;
const TEXT_STEP = 0.1;
export default function create({ root, moduleApi }) {
let stream = null;
let state = null;
let editing = null; // { campus, service } while the editor is open
let pendingRender = false; // a state arrived while the editor was open
let board = null;
let closeEditor = null;
const settings = () => moduleApi.instanceSettings;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
/** The campuses this tile is asked to show; no choice made means all of them. */
function visibleCampuses() {
const all = state?.campuses || [];
const chosen = settings().campuses;
if (!Array.isArray(chosen) || !chosen.length) return all;
return all.filter((campus) => chosen.includes(campus.slug));
}
function textScale() {
const saved = Number(settings().textSize);
return Number.isFinite(saved) ? Math.min(TEXT_MAX, Math.max(TEXT_MIN, saved)) : 1;
}
function serviceLabel(service) {
return service.label ? `${service.label} ${service.text}` : service.text;
}
/** Every service of the campus a resi campus takes its feed from. */
function sourceServices(campus) {
if (!campus.source) return [];
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
return origin ? origin.services : [];
}
/** The mark on the origin service a resi service is taking, if any. */
function upstreamTrouble(campus, service) {
const sourceId = service.mark?.source;
if (!sourceId || !campus.source) return null;
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
const upstream = origin?.services.find((s) => s.id === sourceId);
if (!upstream?.mark || upstream.mark.status === 'ok') return null;
return { origin, upstream };
}
/** Resi services pointed at this broadcast service. */
function dependentsOf(campus, service) {
if (campus.role !== 'broadcast') return [];
const out = [];
for (const other of state?.campuses || []) {
if (other.source !== campus.slug) continue;
for (const theirs of other.services) {
if (theirs.mark?.source === service.id) out.push({ campus: other, service: theirs });
}
}
return out;
}
/* ── drawing ──────────────────────────────────────────────────────── */
function renderServiceRow(campus, service) {
const opts = settings();
const row = el('button', 'st-row');
row.type = 'button';
const status = service.mark?.status || 'ok';
if (status !== 'ok') row.classList.add(`st-${status}`);
const time = el('span', 'st-time', serviceLabel(service));
row.appendChild(time);
if (opts.showDay && service.day) row.appendChild(el('span', 'st-day', service.day));
const chips = el('span', 'st-chips');
const statusDef = STATUSES.find((s) => s.value === status);
if (statusDef?.short) chips.appendChild(el('span', `st-chip st-chip-${status}`, statusDef.short));
// A resi service says which of the origin's services it is taking…
if (opts.showSource && campus.role === 'resi' && service.mark?.source) {
const origin = (state?.campuses || []).find((c) => c.slug === campus.source);
const upstream = origin?.services.find((s) => s.id === service.mark.source);
if (upstream) chips.appendChild(el('span', 'st-chip st-chip-source', `← ${upstream.text}`));
}
// …and turns red when that service is the one in trouble.
const trouble = upstreamTrouble(campus, service);
if (trouble) {
row.classList.add('st-affected');
const what = STATUSES.find((s) => s.value === trouble.upstream.mark.status)?.short || 'marked';
chips.appendChild(el('span', 'st-chip st-chip-skip', `source ${what.toLowerCase()}`));
}
// A broadcast service says who is riding on it.
if (opts.showDependents && campus.role === 'broadcast') {
const riders = dependentsOf(campus, service);
if (riders.length && status !== 'ok') {
chips.appendChild(el('span', 'st-chip st-chip-warn',
`affects ${riders.map((r) => r.campus.name).join(', ')}`));
} else if (riders.length) {
chips.appendChild(el('span', 'st-chip st-chip-quiet', `→ ${riders.length}`));
}
}
row.appendChild(chips);
if (opts.showNotes && service.mark?.note) {
row.appendChild(el('span', 'st-note', service.mark.note));
}
row.addEventListener('click', (event) => {
event.stopPropagation();
openEditor(campus, service, row);
});
return row;
}
function renderCampus(campus) {
const opts = settings();
const card = el('section', 'st-campus');
if (campus.role === 'broadcast') card.classList.add('st-is-broadcast');
const head = el('header', 'st-campus-head');
head.appendChild(el('h3', 'st-campus-name', campus.name));
if (opts.showRole) {
if (campus.role === 'broadcast') {
head.appendChild(el('span', 'st-role st-role-broadcast', 'Broadcast'));
} else if (campus.source) {
head.appendChild(el('span', 'st-role', campus.sourceMissing
? `resi ← ${campus.sourceName} (not listed)`
: `resi ← ${campus.sourceName}`));
} else {
head.appendChild(el('span', 'st-role st-role-unset', 'no resi source'));
}
}
if (campus.manual) head.appendChild(el('span', 'st-role st-role-manual', 'manual'));
card.appendChild(head);
if (opts.showDependents && campus.role === 'broadcast' && campus.dependents?.length) {
card.appendChild(el('p', 'st-feeds', `feeds ${campus.dependents.map((d) => d.name).join(', ')}`));
}
if (!campus.services.length) {
card.appendChild(el('p', 'st-empty', campus.error || 'No times published'));
return card;
}
const list = el('div', 'st-rows');
for (const service of campus.services) list.appendChild(renderServiceRow(campus, service));
card.appendChild(list);
return card;
}
function render() {
if (closeEditor) closeEditor();
root.innerHTML = '';
board = el('div', 'st-board');
board.style.fontSize = `${textScale()}em`;
root.appendChild(board);
if (!state) {
board.appendChild(el('p', 'st-empty', 'Waiting for the server…'));
return;
}
if (!state.configured) {
board.appendChild(el('p', 'st-empty', 'No website address configured — set one in Admin.'));
return;
}
const campuses = visibleCampuses();
if (!campuses.length) {
board.appendChild(el('p', 'st-empty', state.reachable
? 'No campuses to show. Pick some in Admin, or in this tile’s settings.'
: 'No service times yet.'));
} else {
const grid = el('div', settings().layout === 'list' ? 'st-grid st-grid-list' : 'st-grid');
for (const campus of campuses) grid.appendChild(renderCampus(campus));
board.appendChild(grid);
}
if (!state.reachable && state.lastError) {
board.appendChild(el('p', 'st-warn', `Website unreachable — ${state.lastError}. Showing the last times read.`));
}
if (settings().showUpdated && state.lastFetched) {
board.appendChild(el('p', 'st-updated',
`Times read ${new Date(state.lastFetched).toLocaleString([], { hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' })}`));
}
}
/* ── the mark editor ──────────────────────────────────────────────── */
function openEditor(campus, service, anchor) {
if (closeEditor) closeEditor();
editing = { campus: campus.slug, service: service.id };
const panel = el('div', 'st-editor');
panel.appendChild(el('div', 'st-editor-title', `${campus.name} — ${serviceLabel(service)}`));
const mark = service.mark || { status: 'ok', note: '', source: '' };
let status = mark.status || 'ok';
const row = el('div', 'st-editor-status');
const buttons = new Map();
for (const def of STATUSES) {
const button = el('button', 'st-pick', def.label);
button.type = 'button';
if (def.value === status) button.classList.add('active');
button.addEventListener('click', () => {
status = def.value;
for (const [value, node] of buttons) node.classList.toggle('active', value === status);
});
buttons.set(def.value, button);
row.appendChild(button);
}
panel.appendChild(row);
// Which of the origin's services this one is taking — the answer to
// "which service, and where, can be altered".
let sourceSelect = null;
const origins = campus.role === 'resi' ? sourceServices(campus) : [];
if (origins.length) {
const wrap = el('label', 'st-editor-field');
wrap.appendChild(el('span', 'st-editor-label', `Taking from ${campus.sourceName}`));
sourceSelect = el('select', 'st-editor-input');
const none = el('option', null, '— not set —');
none.value = '';
sourceSelect.appendChild(none);
for (const candidate of origins) {
const option = el('option', null, serviceLabel(candidate));
option.value = candidate.id;
if (candidate.id === mark.source) option.selected = true;
sourceSelect.appendChild(option);
}
wrap.appendChild(sourceSelect);
panel.appendChild(wrap);
}
const noteWrap = el('label', 'st-editor-field');
noteWrap.appendChild(el('span', 'st-editor-label', 'Note'));
const note = el('input', 'st-editor-input');
note.type = 'text';
note.maxLength = 280;
note.value = mark.note || '';
note.placeholder = 'What production needs to know';
noteWrap.appendChild(note);
panel.appendChild(noteWrap);
const actions = el('div', 'st-editor-actions');
const save = el('button', 'st-pick st-save', 'Save');
save.type = 'button';
const cancel = el('button', 'st-pick', 'Cancel');
cancel.type = 'button';
actions.append(save, cancel);
panel.appendChild(actions);
async function commit() {
const body = {
campus: campus.slug,
service: service.id,
status,
note: note.value.trim(),
source: sourceSelect ? sourceSelect.value : (mark.source || ''),
};
close();
try {
const response = await moduleApi.fetch('/mark', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`${response.status}`);
} catch (err) {
moduleApi.setStatus('error', `Could not save the mark: ${err.message || err}`);
}
}
save.addEventListener('click', commit);
cancel.addEventListener('click', () => close());
note.addEventListener('keydown', (event) => {
if (event.key === 'Enter') commit();
if (event.key === 'Escape') close();
});
panel.addEventListener('click', (event) => event.stopPropagation());
function onOutside() { close(); }
function onKey(event) { if (event.key === 'Escape') close(); }
function close() {
if (closeEditor !== close) return;
panel.remove();
anchor?.classList.remove('st-open');
root.removeEventListener('click', onOutside);
document.removeEventListener('keydown', onKey);
closeEditor = null;
editing = null;
if (pendingRender) {
pendingRender = false;
render(); // closeEditor is already null, so this cannot re-enter
}
}
closeEditor = close;
anchor?.classList.add('st-open');
board.appendChild(panel);
root.addEventListener('click', onOutside);
document.addEventListener('keydown', onKey);
note.focus();
note.select();
}
/* ── lifecycle ────────────────────────────────────────────────────── */
function applyState(next) {
state = next;
if (!state) {
moduleApi.setStatus('connecting', 'Waiting for the server');
} else if (!state.configured) {
moduleApi.setStatus('error', 'No website address configured');
} else if (!state.reachable) {
moduleApi.setStatus('error', state.lastError
? `Website unreachable — ${state.lastError}`
: 'Website unreachable');
} else if (state.lastError) {
moduleApi.setStatus('connecting', state.lastError);
} else {
const count = (state.campuses || []).reduce((sum, c) => sum + c.services.length, 0);
moduleApi.setStatus('ok', `${count} service(s) across ${(state.campuses || []).length} campus(es)`);
}
// Re-rendering would close an open editor mid-sentence, so hold the change
// back while someone is typing — and draw it the moment they are done, or
// the board would sit stale until the next change happened to arrive.
if (editing) {
pendingRender = true;
return;
}
render();
}
async function loadState() {
try {
const response = await moduleApi.fetch('/state');
const body = await response.json();
applyState(body.state);
} catch {
/* the stream will bring it */
}
}
function setTextSize(delta) {
const next = Math.min(TEXT_MAX, Math.max(TEXT_MIN, textScale() + delta));
moduleApi.saveInstanceSettings({ textSize: Number(next.toFixed(2)) });
if (board) board.style.fontSize = `${next}em`;
}
return {
start() {
moduleApi.setStatus('connecting', 'Reading service times');
render();
moduleApi.header.addButton({
icon: ICON_REFRESH,
title: 'Refresh times',
onClick: async () => {
moduleApi.setStatus('connecting', 'Re-reading the website');
try {
const response = await moduleApi.fetch('/refresh', { method: 'POST' });
const body = await response.json();
if (body.state) applyState(body.state);
} catch (err) {
moduleApi.setStatus('error', `Refresh failed: ${err.message || err}`);
}
},
});
moduleApi.header.addButton({
icon: ICON_CLEAR,
title: 'Clear marks',
onClick: async () => {
const marked = (state?.campuses || []).reduce(
(sum, campus) => sum + campus.services.filter((s) => s.mark).length, 0
);
if (!marked) return;
// Marks are shared by every campus watching — never wipe them silently.
if (!window.confirm(`Clear ${marked} mark${marked === 1 ? '' : 's'} for every campus?`)) return;
try {
await moduleApi.fetch('/clear', { method: 'POST' });
} catch (err) {
moduleApi.setStatus('error', `Could not clear marks: ${err.message || err}`);
}
},
});
moduleApi.header.addButton({
icon: ICON_SMALLER,
title: 'Smaller text',
onClick: () => setTextSize(-TEXT_STEP),
});
moduleApi.header.addButton({
icon: ICON_BIGGER,
title: 'Larger text',
onClick: () => setTextSize(TEXT_STEP),
});
stream = moduleApi.sse('/stream', {
open() {
// The shared connection was (re)made — we may have missed a change.
loadState();
},
error() {
moduleApi.setStatus('error', 'Lost the connection to ProdDash');
},
events: {
state(event) {
try {
applyState(JSON.parse(event.data));
} catch { /* a truncated frame; the next one will be whole */ }
},
},
});
loadState();
},
stop() {
pendingRender = false; // nothing to catch up on: the tile is going away
if (closeEditor) closeEditor();
stream?.close();
stream = null;
state = null;
editing = null;
board = null;
root.innerHTML = '';
},
};
}
+108
View File
@@ -0,0 +1,108 @@
{
"id": "service-times",
"name": "Service Times",
"version": "1.0.0",
"proddash": ">=1.4.0",
"description": "Sunday service times for every campus, marked up for replays and services production cannot use",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 6, "h": 5 },
"configGroups": {
"Website": {
"help": "Service times are read from the campus pages at <site>/locations/<campus>/ and refreshed on their own, so the board follows the website whenever the times change. Campuses are discovered from the site's sitemap — tick the ones production is responsible for."
},
"Broadcast": {
"help": "Which campuses originate the Sunday broadcast, and which campus each of the others takes its resi from. This is what lets the board show, when a service is marked unusable, every campus affected by it."
},
"Manual campuses": {
"collapsed": "whenSet",
"help": "A stopgap for a campus the website has not published yet. The website stays the source of truth: as soon as it carries a campus by this name, its times take over and the health line above says the line here can be removed."
},
"Marks": {
"collapsed": true,
"help": "Marks are shared by every tile and every browser, and survive a restart of ProdDash."
}
},
"configSchema": {
"siteUrl": {
"type": "string",
"label": "Website",
"default": "https://waterschurch.org",
"group": "Website",
"help": "The church website's address. Campus pages are read from /locations/ underneath it."
},
"campuses": {
"type": "multiselect",
"label": "Campuses",
"default": ["north-attleboro", "woonsocket", "southcoast", "apollo-beach"],
"group": "Website",
"optionsRoute": "/discovered",
"help": "Leave every campus unticked to follow all of them."
},
"refreshMinutes": {
"type": "number",
"label": "Refresh (min)",
"default": 360,
"group": "Website",
"help": "How often the website is re-read. Times change a few times a year, so this can be slow; a failed read retries every minute and the last good times stay on screen."
},
"broadcast": {
"type": "multiselect",
"label": "Broadcast campuses",
"default": ["north-attleboro", "apollo-beach"],
"group": "Broadcast",
"optionsRoute": "/campuses",
"help": "The campuses the broadcast originates from."
},
"feeds": {
"type": "text",
"label": "Resi sources",
"default": "woonsocket = north-attleboro\nsouthcoast = north-attleboro",
"group": "Broadcast",
"help": "One campus per line: campus = the broadcast campus it takes resi from. Names or slugs both work."
},
"manual": {
"type": "text",
"label": "Campuses and times",
"default": "",
"group": "Manual campuses",
"help": "One campus per line: Name | Day | 9:00a, 11:00a. The day is optional and defaults to Sunday. Lines starting with # are ignored."
},
"retentionHours": {
"type": "number",
"label": "Marks clear after (h)",
"default": 36,
"group": "Marks",
"help": "A mark disappears this long after it was made, so Saturday's and Sunday's notes are gone before the next week. Set 0 to keep marks until they are cleared by hand."
}
},
"instanceGroups": {
"Show": { "collapsed": true, "columns": 2 }
},
"instanceSchema": {
"campuses": {
"type": "multiselect",
"label": "Campuses",
"optionsRoute": "/campuses",
"columns": 2,
"allByDefault": true
},
"layout": {
"type": "select",
"label": "Layout",
"default": "cards",
"options": [
{ "value": "cards", "label": "Cards" },
{ "value": "list", "label": "List" }
]
},
"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" }
}
}
+413
View File
@@ -0,0 +1,413 @@
'use strict';
/**
* Service Times — reading the church website.
*
* Pure functions, no I/O, so every shape below can be checked without the
* network (parse.test.js). The server entry does the fetching.
*
* Every campus page is built from the same page-builder blocks: a "Times"
* heading, the day in bold, then the service times under it, ending before
* the "Get Involved" heading. Bounding the slice that way is what keeps the
* footer's main-office hours out of the service list — and some campuses
* (Apollo Beach) carry their own "Office Hours" block inside the Times
* section, which is why STOP_HEADINGS ends the walk early as well.
*
* The four shapes seen in the wild, all handled by walkSection():
*
* Woonsocket / Apollo Beach Sunday → 9:00a, 11:00a
* 9:00a & 11:00a
* North Attleboro Sunday → 7:30a, 9:30a, 11:30a
* 7:30a<br>9:30a<br>11:30a
* Orlando Sundays → Welcome 10:00a,
* Welcome 10:00am Start 10:30a
* Start 10:30am
*/
const WEEKDAYS = [
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
];
/** Headings that end the service list wherever they turn up in the section. */
const STOP_HEADINGS = /^(office\s*hours?|get\s*involved|location\s*pastor|contact|address|events?)\b/i;
/** A time: "9:00a", "11:30am", "7a", "10:00 a.m." — never "1 person". */
const TIME_RE = /(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*(?:m\.?)?(?![a-z])/gi;
/** "9:00a-4:30p" — an hours range, not two services. */
const RANGE_RE = /\d\s*(?::\d{2})?\s*[ap]\.?m?\.?\s*[-–—]\s*\d/i;
const NAMED_ENTITIES = {
amp: '&', nbsp: ' ', quot: '"', apos: "'", lt: '<', gt: '>',
ndash: '–', mdash: '—', hellip: '…', middot: '·', bull: '•',
rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”',
};
function fromCodePoint(n) {
try {
return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : '';
} catch {
return '';
}
}
function decodeEntities(input) {
return String(input == null ? '' : input)
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => fromCodePoint(parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, dec) => fromCodePoint(parseInt(dec, 10)))
.replace(/&([a-z]+);/gi, (whole, name) => {
const value = NAMED_ENTITIES[name.toLowerCase()];
return value === undefined ? whole : value;
});
}
/**
* HTML → the lines a reader would see. Block ends and <br> become line
* breaks so "7:30a<br>9:30a" stays two services rather than one run-on.
*/
function htmlToLines(html) {
return String(html == null ? '' : html)
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|ul|ol|h[1-6]|td|tr|span|strong|b|em|i|a)\s*>/gi, '\n')
.replace(/<[^>]*>/g, ' ')
.split('\n')
.map((line) => decodeEntities(line).replace(/[   ]/g, ' ').replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
/** Title Case a slug: "north-attleboro" → "North Attleboro". */
function nameFromSlug(slug) {
return String(slug || '')
.split(/[-_/]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function slugify(name) {
return String(name == null ? '' : name)
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/** A weekday heading — "Sunday", "Sundays", "SUNDAY" — and nothing else. */
function dayFromLine(line) {
const cleaned = String(line || '').toLowerCase().replace(/[^a-z]+/g, '');
if (!cleaned) return '';
for (const day of WEEKDAYS) {
if (cleaned === day || cleaned === `${day}s`) {
return day.charAt(0).toUpperCase() + day.slice(1);
}
}
return '';
}
function pad2(n) {
return String(n).padStart(2, '0');
}
/** "9:00a" → { hour24: 9, minute: 0, text: "9:00a" }; 12a/12p handled. */
function toService(hourRaw, minuteRaw, meridiemRaw) {
const hour12 = Number(hourRaw);
const minute = Number(minuteRaw || 0);
const pm = String(meridiemRaw || '').toLowerCase() === 'p';
if (!Number.isFinite(hour12) || hour12 < 1 || hour12 > 12) return null;
if (!Number.isFinite(minute) || minute > 59) return null;
const hour24 = pm ? (hour12 === 12 ? 12 : hour12 + 12) : hour12 === 12 ? 0 : hour12;
return {
hour24,
minute,
minutes: hour24 * 60 + minute,
text: `${hour12}:${pad2(minute)}${pm ? 'p' : 'a'}`,
};
}
/**
* Times on one line, with the words in front of the first one as a label
* ("Welcome 10:00am" → label "Welcome"). A line holding an hours range
* ("9:00a-4:30p") is office hours, not services, and yields nothing.
*/
function timesFromLine(line) {
const text = String(line || '');
if (RANGE_RE.test(text)) return [];
TIME_RE.lastIndex = 0;
const found = [];
let first = -1;
let match = TIME_RE.exec(text);
while (match) {
const service = toService(match[1], match[2], match[3]);
if (service) {
if (first < 0) first = match.index;
found.push(service);
}
match = TIME_RE.exec(text);
}
if (!found.length) return [];
const label = first > 0
? text.slice(0, first).replace(/[\s|,;:&/–—-]+$/, '').trim()
: '';
// A label belongs to a single time ("Start 10:30am"); a list of times on
// one line ("9:00a & 11:00a") shares none.
return found.map((service) => ({ ...service, label: found.length === 1 ? label : '' }));
}
/**
* Walk the lines of a Times section into services, stopping at the first
* stop-heading. Lines before any weekday heading still count — a campus
* that lists bare times under "Times" is read as Sunday.
*/
function walkSection(lines, { defaultDay = 'Sunday' } = {}) {
const services = [];
let day = '';
for (const line of lines) {
if (STOP_HEADINGS.test(line)) break;
const heading = dayFromLine(line);
if (heading) {
day = heading;
continue;
}
for (const service of timesFromLine(line)) {
services.push({ ...service, day: day || defaultDay });
}
}
return services;
}
/** Give every service a stable id — marks are keyed on it, so it must not drift. */
function withIds(services) {
const seen = new Map();
return services.map((service) => {
const base = [
pad2(service.hour24) + pad2(service.minute),
slugify(service.label),
].filter(Boolean).join('-');
const count = (seen.get(base) || 0) + 1;
seen.set(base, count);
return { ...service, id: count === 1 ? base : `${base}-${count}` };
});
}
/** Drop exact duplicates (same day + time + label) while keeping order. */
function dedupe(services) {
const seen = new Set();
return services.filter((service) => {
const key = `${service.day}|${service.minutes}|${service.label.toLowerCase()}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/**
* The slice of a campus page between the "Times" heading and whatever ends
* it. Returns '' when the page has no Times heading, which the caller
* reports rather than guessing at.
*/
function timesSection(html) {
const text = String(html == null ? '' : html);
const start = /<([a-z0-9]+)\b[^>]*>\s*times\s*<\/\1\s*>/i.exec(text)
|| />\s*times\s*</i.exec(text);
if (!start) return '';
const from = start.index + start[0].length;
const rest = text.slice(from);
const end = /<([a-z0-9]+)\b[^>]*>\s*get\s+involved\s*<\/\1\s*>/i.exec(rest)
|| />\s*get\s+involved\s*</i.exec(rest);
return end ? rest.slice(0, end.index) : rest.slice(0, 20000);
}
/**
* One campus page → { slug, name, day, services, error }. `error` is set
* (and services empty) when the page no longer looks the way this parser
* expects, so the admin health line can say the site changed rather than
* quietly showing a campus with no services.
*/
function parseCampusPage(html, { slug = '', name = '' } = {}) {
const campusSlug = slugify(slug);
const campusName = name || nameFromSlug(campusSlug);
const section = timesSection(html);
if (!section) {
return { slug: campusSlug, name: campusName, services: [], error: 'No "Times" section on the page' };
}
const services = withIds(dedupe(walkSection(htmlToLines(section))));
if (!services.length) {
return { slug: campusSlug, name: campusName, services: [], error: 'No service times under "Times"' };
}
return { slug: campusSlug, name: campusName, services, error: '' };
}
/**
* Campus page slugs from a sitemap and/or any page's links. The sitemap is
* the fuller list — it carries campuses the navigation does not link yet.
*/
function discoverCampusSlugs(...sources) {
const slugs = new Set();
for (const source of sources) {
const text = String(source == null ? '' : source);
const re = /\/locations\/([a-z0-9][a-z0-9-]*)\/?/gi;
let match = re.exec(text);
while (match) {
const slug = slugify(match[1]);
if (slug && slug !== 'locations') slugs.add(slug);
match = re.exec(text);
}
}
return [...slugs].sort();
}
/**
* The admin "Manual campuses" text: one campus per line,
*
* Name | Day | 9:00a, 11:00a
*
* Day is optional and defaults to Sunday: "Plymouth | 9:00a & 11:00a".
* A line the parser cannot read is returned in `errors` so the admin page
* can say which line, rather than the campus silently not appearing.
*/
function parseManualCampuses(text) {
const campuses = [];
const errors = [];
const lines = String(text == null ? '' : text).split(/\r?\n/);
lines.forEach((raw, index) => {
const line = raw.trim();
if (!line || line.startsWith('#')) return;
const parts = line.split('|').map((part) => part.trim());
const name = parts.shift() || '';
if (!name) return;
if (!parts.length) {
errors.push(`Line ${index + 1}: "${line}" has no times`);
return;
}
let day = '';
if (parts.length > 1) {
day = dayFromLine(parts[0]) || '';
if (day) parts.shift();
}
const services = withIds(dedupe(
walkSection(parts.join('\n').split('\n'), { defaultDay: day || 'Sunday' })
));
if (!services.length) {
errors.push(`Line ${index + 1}: no times found in "${line}"`);
return;
}
campuses.push({ slug: slugify(name), name, services, manual: true, error: '' });
});
return { campuses, errors };
}
/**
* Website campuses and manual ones into the one list the tiles draw.
*
* The website wins: a manual campus is a stopgap for a site that has not
* caught up, so as soon as the site publishes that campus (same slug) its
* times take over and the manual entry is marked `supersededBy` — the admin
* page says so, and the operator can delete the line.
*/
function mergeCampuses(siteCampuses, manualCampuses) {
const merged = [];
const bySlug = new Map();
for (const campus of siteCampuses || []) {
if (!campus || !campus.slug) continue;
const entry = { ...campus, manual: false, superseded: false };
bySlug.set(campus.slug, entry);
merged.push(entry);
}
for (const campus of manualCampuses || []) {
if (!campus || !campus.slug) continue;
const fromSite = bySlug.get(campus.slug);
if (fromSite) {
// The site has it now — its times are the ones that matter.
if (fromSite.services.length) {
fromSite.supersededManual = true;
continue;
}
// The page exists but gave us nothing usable; the manual line still helps.
fromSite.services = campus.services;
fromSite.manual = true;
fromSite.error = '';
continue;
}
const entry = { ...campus, superseded: false };
bySlug.set(campus.slug, entry);
merged.push(entry);
}
return merged;
}
/**
* The admin "Resi sources" text: one campus per line,
*
* woonsocket = north-attleboro
*
* Names work as well as slugs — both sides are slugified, so an operator can
* write what they say out loud. "->", ":" and "→" read as "=".
*/
function parseFeeds(text) {
const feeds = new Map();
const errors = [];
String(text == null ? '' : text).split(/\r?\n/).forEach((raw, index) => {
const line = raw.trim();
if (!line || line.startsWith('#')) return;
const match = /^(.+?)\s*(?:=|->|:|→)\s*(.+)$/.exec(line);
if (!match) {
errors.push(`Line ${index + 1}: "${line}" is not "campus = broadcast campus"`);
return;
}
const from = slugify(match[1]);
const to = slugify(match[2]);
if (!from || !to) {
errors.push(`Line ${index + 1}: "${line}" is missing a campus`);
return;
}
feeds.set(from, to);
});
return { feeds, errors };
}
/**
* Give every campus its production role: which ones originate the broadcast,
* which one each of the others takes its resi from, and — the question the
* board exists to answer — who is riding on each origin.
*
* A resi campus pointed at a campus that is not on the board keeps the
* pointer and is flagged (`sourceMissing`) rather than quietly reading as
* unassigned: a typo in the admin text has to be visible, not invisible.
*/
function assignRoles(campuses, { broadcast = [], feeds = new Map() } = {}) {
const origins = new Set([...broadcast].map((slug) => slugify(slug)).filter(Boolean));
const shaped = (campuses || []).map((campus) => {
const role = origins.has(campus.slug) ? 'broadcast' : 'resi';
const source = role === 'broadcast' ? '' : feeds.get(campus.slug) || '';
return { ...campus, role, source, sourceName: '', sourceMissing: false, dependents: [] };
});
const bySlug = new Map(shaped.map((campus) => [campus.slug, campus]));
for (const campus of shaped) {
if (!campus.source) continue;
const origin = bySlug.get(campus.source);
campus.sourceName = origin ? origin.name : campus.source;
campus.sourceMissing = !origin;
if (origin) origin.dependents.push({ slug: campus.slug, name: campus.name });
}
return shaped;
}
module.exports = {
WEEKDAYS,
decodeEntities,
htmlToLines,
nameFromSlug,
slugify,
dayFromLine,
timesFromLine,
walkSection,
timesSection,
parseCampusPage,
discoverCampusSlugs,
parseManualCampuses,
mergeCampuses,
parseFeeds,
assignRoles,
};
+313
View File
@@ -0,0 +1,313 @@
'use strict';
/**
* Unit tests for the Service Times parser.
*
* node modules/service-times/parse.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. The
* fixtures are trimmed from the real campus pages, so each one stands for a
* shape the website actually publishes — including Apollo Beach's "Office
* Hours" block, which sits inside the Times section and must never be read
* as a service.
*/
const assert = require('assert');
const {
htmlToLines,
slugify,
dayFromLine,
timesFromLine,
timesSection,
parseCampusPage,
discoverCampusSlugs,
parseManualCampuses,
mergeCampuses,
parseFeeds,
assignRoles,
} = require('./parse');
let passed = 0;
function test(name, fn) {
try {
fn();
passed += 1;
console.log(` ok ${name}`);
} catch (err) {
console.error(`FAIL ${name}\n ${err.message}`);
process.exit(1);
}
}
/** The page-builder wrapper every campus's Times block sits in. */
const page = (inner) => `
<div class="wpb_wrapper"><h5 class="q_title">Address</h5><p>1 Example Road</p></div>
<div class="wpb_wrapper"><h5 class="q_title">Times</h5>
${inner}
</div>
<div class="wpb_wrapper"><h5 class="q_title">Get Involved</h5><p>Give now</p></div>
<div class="footer"><p>Mon &#8211; Thu 9a &#8211; 4:30p</p></div>
`;
const WOONSOCKET = page('<p><strong>Sunday</strong></p><p>9:00a &amp; 11:00a</p>');
const NORTH_ATTLEBORO = page('<p><strong>Sunday</strong></p><p>7:30a<br />9:30a<br />11:30a</p>');
const ORLANDO = page('<p><b>Sundays</b></p><p>Welcome 10:00am</p><p>Start 10:30am</p>');
const SOUTHCOAST = page('<p><strong>Sunday</strong></p><p>10:30a</p>');
const APOLLO_BEACH = page(
'<p><strong style="color:#333">Sunday</strong></p>'
+ '<p><span style="color:#333">9:00a &amp; 11:00a</span></p>'
+ '<h5 class="q_title">Office Hours</h5><p>Mon-Wed 9:00a-4:30p</p>'
);
const times = (html, slug) => parseCampusPage(html, { slug }).services.map((s) => s.text);
console.log('campus pages');
test('Woonsocket: two times joined by "&" on one line', () => {
assert.deepStrictEqual(times(WOONSOCKET, 'woonsocket'), ['9:00a', '11:00a']);
});
test('North Attleboro: three times separated by <br>', () => {
assert.deepStrictEqual(times(NORTH_ATTLEBORO, 'north-attleboro'), ['7:30a', '9:30a', '11:30a']);
});
test('Southcoast: a single service', () => {
assert.deepStrictEqual(times(SOUTHCOAST, 'southcoast'), ['10:30a']);
});
test('Orlando: "am" spelling, one labelled time per line', () => {
const services = parseCampusPage(ORLANDO, { slug: 'orlando' }).services;
assert.deepStrictEqual(services.map((s) => s.text), ['10:00a', '10:30a']);
assert.deepStrictEqual(services.map((s) => s.label), ['Welcome', 'Start']);
});
test('Apollo Beach: the Office Hours block inside Times is not a service', () => {
assert.deepStrictEqual(times(APOLLO_BEACH, 'apollo-beach'), ['9:00a', '11:00a']);
});
/* Apollo Beach's real page lists an hours *range* under Office Hours, which the
range guard rejects on its own — so the test above passes even if the
stop-heading breaks. This fixture puts plain times there instead, where only
the stop-heading can keep them out: without it, the 2:00p becomes a service. */
const OFFICE_HOURS_PLAIN = page(
'<p><strong>Sunday</strong></p><p>9:00a &amp; 11:00a</p>'
+ '<h5 class="q_title">Office Hours</h5><p>Mon 9:00a</p><p>Thu 2:00p</p>'
);
test('an Office Hours block ends the service list, range or not', () => {
assert.deepStrictEqual(times(OFFICE_HOURS_PLAIN, 'apollo-beach'), ['9:00a', '11:00a']);
});
test('the footer\'s main-office hours never reach the services', () => {
for (const [html, slug] of [[WOONSOCKET, 'woonsocket'], [SOUTHCOAST, 'southcoast']]) {
assert.ok(!times(html, slug).includes('4:30p'), `${slug} picked up the footer`);
}
});
test('names and days come out as the site writes them', () => {
const campus = parseCampusPage(NORTH_ATTLEBORO, { slug: 'north-attleboro' });
assert.strictEqual(campus.name, 'North Attleboro');
assert.deepStrictEqual([...new Set(campus.services.map((s) => s.day))], ['Sunday']);
});
test('ids are stable and unique — marks are keyed on them', () => {
const first = parseCampusPage(ORLANDO, { slug: 'orlando' }).services.map((s) => s.id);
const again = parseCampusPage(ORLANDO, { slug: 'orlando' }).services.map((s) => s.id);
assert.deepStrictEqual(first, ['1000-welcome', '1030-start']);
assert.deepStrictEqual(first, again);
assert.strictEqual(new Set(first).size, first.length);
});
test('a page that no longer has a Times section reports it', () => {
const campus = parseCampusPage('<h1>Redirected elsewhere</h1>', { slug: 'norwood' });
assert.deepStrictEqual(campus.services, []);
assert.match(campus.error, /Times/);
});
console.log('pieces');
test('24-hour values and sort order', () => {
const [nine] = timesFromLine('9:00a');
const [noon] = timesFromLine('12:00p');
const [midnight] = timesFromLine('12:00a');
const [evening] = timesFromLine('6:30p');
assert.strictEqual(nine.hour24, 9);
assert.strictEqual(noon.hour24, 12);
assert.strictEqual(midnight.hour24, 0);
assert.strictEqual(evening.minutes, 18 * 60 + 30);
});
test('an hours range is not a service list', () => {
assert.deepStrictEqual(timesFromLine('Mon-Wed 9:00a-4:30p'), []);
assert.deepStrictEqual(timesFromLine('9a – 4:30p'), []);
});
test('prose that merely contains a number is not a time', () => {
assert.deepStrictEqual(timesFromLine('1 pastor, 2 people'), []);
assert.deepStrictEqual(timesFromLine('Apollo Beach campus'), []);
});
test('weekday headings, singular or plural, in any case', () => {
assert.strictEqual(dayFromLine('Sunday'), 'Sunday');
assert.strictEqual(dayFromLine('SUNDAYS'), 'Sunday');
assert.strictEqual(dayFromLine(' Saturday '), 'Saturday');
assert.strictEqual(dayFromLine('Sunday 9:00a'), '');
assert.strictEqual(dayFromLine('Get Involved'), '');
});
test('<br> and block ends become line breaks', () => {
assert.deepStrictEqual(htmlToLines('<p>7:30a<br />9:30a</p>'), ['7:30a', '9:30a']);
assert.deepStrictEqual(htmlToLines('<p>9:00a &amp; 11:00a</p>'), ['9:00a & 11:00a']);
});
test('the Times slice stops at Get Involved', () => {
const section = timesSection(WOONSOCKET);
assert.ok(section.includes('9:00a'));
assert.ok(!section.includes('Give now'));
});
test('campus slugs are discovered from a sitemap', () => {
const xml = `<url><loc>https://waterschurch.org/locations/</loc></url>
<url><loc>https://waterschurch.org/locations/north-attleboro/</loc></url>
<url><loc>https://waterschurch.org/locations/norwood/</loc></url>`;
assert.deepStrictEqual(discoverCampusSlugs(xml), ['north-attleboro', 'norwood']);
});
test('slugs survive punctuation and case', () => {
assert.strictEqual(slugify('North Attleboro'), 'north-attleboro');
assert.strictEqual(slugify(' Apollo Beach! '), 'apollo-beach');
});
console.log('manual campuses');
test('name, day and times — day optional', () => {
const { campuses, errors } = parseManualCampuses(
'Plymouth | Sunday | 9:00a, 11:00a\nQuincy | 10:30a\n'
);
assert.deepStrictEqual(errors, []);
assert.deepStrictEqual(campuses.map((c) => c.name), ['Plymouth', 'Quincy']);
assert.deepStrictEqual(campuses[0].services.map((s) => s.text), ['9:00a', '11:00a']);
assert.deepStrictEqual(campuses[1].services.map((s) => s.day), ['Sunday']);
assert.ok(campuses[0].manual);
});
test('blank lines and # comments are skipped; a line with no time is reported', () => {
const { campuses, errors } = parseManualCampuses('\n# not yet on the site\nPlymouth | soon\n');
assert.deepStrictEqual(campuses, []);
assert.strictEqual(errors.length, 1);
assert.match(errors[0], /Line 3/);
});
console.log('merging');
test('the website wins once it publishes a campus the operator added by hand', () => {
const site = [{ slug: 'plymouth', name: 'Plymouth', services: [{ id: '0900', text: '9:00a' }], error: '' }];
const manual = parseManualCampuses('Plymouth | 10:30a').campuses;
const merged = mergeCampuses(site, manual);
assert.strictEqual(merged.length, 1);
assert.deepStrictEqual(merged[0].services.map((s) => s.text), ['9:00a']);
assert.strictEqual(merged[0].manual, false);
assert.ok(merged[0].supersededManual, 'the admin page should be able to say the line can go');
});
test('a manual campus the site does not have yet is kept', () => {
const merged = mergeCampuses([], parseManualCampuses('Plymouth | 10:30a').campuses);
assert.deepStrictEqual(merged.map((c) => c.name), ['Plymouth']);
assert.ok(merged[0].manual);
});
test('a site page that parses to nothing falls back to the manual line', () => {
const site = [{ slug: 'plymouth', name: 'Plymouth', services: [], error: 'No "Times" section on the page' }];
const merged = mergeCampuses(site, parseManualCampuses('Plymouth | 10:30a').campuses);
assert.deepStrictEqual(merged[0].services.map((s) => s.text), ['10:30a']);
assert.strictEqual(merged[0].error, '');
assert.ok(merged[0].manual);
});
console.log('resi sources');
test('campus = broadcast campus, by name or slug, with -> and : accepted', () => {
const { feeds, errors } = parseFeeds(
'Woonsocket = North Attleboro\nsouthcoast -> north-attleboro\n# a comment\n\nOrlando: Apollo Beach'
);
assert.deepStrictEqual(errors, []);
assert.strictEqual(feeds.get('woonsocket'), 'north-attleboro');
assert.strictEqual(feeds.get('southcoast'), 'north-attleboro');
assert.strictEqual(feeds.get('orlando'), 'apollo-beach');
});
test('a line that names no source is reported, not dropped in silence', () => {
const { feeds, errors } = parseFeeds('woonsocket\nsouthcoast = north-attleboro');
assert.strictEqual(feeds.size, 1);
assert.strictEqual(errors.length, 1);
assert.match(errors[0], /Line 1/);
});
console.log('roles and dependents');
/** The four campuses production runs, set up the way the booth runs them. */
const BOARD = [
{ slug: 'north-attleboro', name: 'North Attleboro', services: [{ id: '0730' }, { id: '0930' }, { id: '1130' }] },
{ slug: 'woonsocket', name: 'Woonsocket', services: [{ id: '0900' }, { id: '1100' }] },
{ slug: 'southcoast', name: 'Southcoast', services: [{ id: '1030' }] },
{ slug: 'apollo-beach', name: 'Apollo Beach', services: [{ id: '0900' }, { id: '1100' }] },
];
const SETUP = {
broadcast: ['north-attleboro', 'apollo-beach'],
feeds: parseFeeds('woonsocket = north-attleboro\nsouthcoast = north-attleboro').feeds,
};
const byName = (campuses, name) => campuses.find((c) => c.name === name);
test('the broadcast campuses originate and take resi from nobody', () => {
const roles = assignRoles(BOARD, SETUP);
for (const name of ['North Attleboro', 'Apollo Beach']) {
assert.strictEqual(byName(roles, name).role, 'broadcast', name);
assert.strictEqual(byName(roles, name).source, '', `${name} should have no source`);
}
});
test('every other campus takes resi from the campus it is mapped to', () => {
const roles = assignRoles(BOARD, SETUP);
for (const name of ['Woonsocket', 'Southcoast']) {
const campus = byName(roles, name);
assert.strictEqual(campus.role, 'resi', name);
assert.strictEqual(campus.source, 'north-attleboro', name);
assert.strictEqual(campus.sourceName, 'North Attleboro', name);
assert.strictEqual(campus.sourceMissing, false, name);
}
});
test('an origin lists who is riding on it — the answer the board exists for', () => {
const roles = assignRoles(BOARD, SETUP);
assert.deepStrictEqual(
byName(roles, 'North Attleboro').dependents.map((d) => d.name),
['Woonsocket', 'Southcoast']
);
assert.deepStrictEqual(byName(roles, 'Apollo Beach').dependents, []);
});
test('a source that is not on the board is flagged, not read as unassigned', () => {
const roles = assignRoles(BOARD, {
broadcast: ['north-attleboro'],
feeds: parseFeeds('woonsocket = attleboro-north').feeds, // a typo
});
const woonsocket = byName(roles, 'Woonsocket');
assert.strictEqual(woonsocket.source, 'attleboro-north');
assert.strictEqual(woonsocket.sourceMissing, true);
assert.deepStrictEqual(byName(roles, 'North Attleboro').dependents, []);
});
test('a campus with no mapping reads as having no source at all', () => {
const roles = assignRoles(BOARD, { broadcast: ['north-attleboro'], feeds: new Map() });
assert.strictEqual(byName(roles, 'Southcoast').source, '');
assert.strictEqual(byName(roles, 'Southcoast').sourceMissing, false);
});
test('assignRoles leaves the campuses it was handed alone', () => {
const input = [{ slug: 'woonsocket', name: 'Woonsocket', services: [] }];
assignRoles(input, SETUP);
assert.strictEqual(input[0].role, undefined, 'the caller’s objects must not be rewritten');
});
console.log(`\n${passed} tests passed`);
+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(' '),
})),
});
},
};
},
};
+297
View File
@@ -0,0 +1,297 @@
/* Service Times — scoped under its tiles via [data-module="service-times"],
styled with the shell's CSS variables only so themes reach into it. */
[data-module="service-times"] .st-board {
position: absolute;
inset: 0;
overflow: auto;
padding: 8px;
color: var(--text);
font-size: 1em;
}
[data-module="service-times"] .st-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
gap: 8px;
align-items: start;
}
[data-module="service-times"] .st-grid-list {
grid-template-columns: 1fr;
}
/* ── a campus ─────────────────────────────────────────────────────── */
[data-module="service-times"] .st-campus {
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 92%, var(--text) 8%);
padding: 7px 8px 8px;
min-width: 0;
}
[data-module="service-times"] .st-is-broadcast {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
}
[data-module="service-times"] .st-campus-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 5px;
margin-bottom: 5px;
}
[data-module="service-times"] .st-campus-name {
margin: 0;
font-size: 1.02em;
font-weight: 700;
letter-spacing: 0.01em;
color: var(--text);
}
[data-module="service-times"] .st-role {
font-size: 0.72em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
white-space: nowrap;
}
[data-module="service-times"] .st-role-broadcast {
color: var(--accent-text, var(--text));
background: var(--accent);
border-radius: 999px;
padding: 1px 7px;
}
[data-module="service-times"] .st-role-manual {
color: var(--muted);
border: 1px dashed var(--border);
border-radius: 999px;
padding: 0 6px;
}
[data-module="service-times"] .st-role-unset {
color: var(--warn);
}
[data-module="service-times"] .st-feeds {
margin: 0 0 5px;
font-size: 0.76em;
color: var(--muted);
}
/* ── a service ────────────────────────────────────────────────────── */
[data-module="service-times"] .st-rows {
display: flex;
flex-direction: column;
gap: 3px;
}
[data-module="service-times"] .st-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
width: 100%;
text-align: left;
font: inherit;
color: var(--text);
background: color-mix(in srgb, var(--text) 5%, transparent);
border: 1px solid transparent;
border-left: 3px solid color-mix(in srgb, var(--text) 22%, transparent);
border-radius: 5px;
padding: 4px 7px;
cursor: pointer;
}
[data-module="service-times"] .st-row:hover,
[data-module="service-times"] .st-row:focus-visible {
border-color: var(--border);
border-left-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
[data-module="service-times"] .st-row.st-open {
border-color: var(--accent);
}
[data-module="service-times"] .st-time {
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
[data-module="service-times"] .st-day {
font-size: 0.74em;
color: var(--muted);
}
[data-module="service-times"] .st-chips {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
margin-left: auto;
}
[data-module="service-times"] .st-chip {
font-size: 0.7em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
border-radius: 999px;
padding: 1px 7px;
white-space: nowrap;
}
[data-module="service-times"] .st-chip-issue,
[data-module="service-times"] .st-chip-warn {
color: var(--warn);
background: color-mix(in srgb, var(--warn) 18%, transparent);
}
[data-module="service-times"] .st-chip-replay {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
[data-module="service-times"] .st-chip-skip {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
[data-module="service-times"] .st-chip-source,
[data-module="service-times"] .st-chip-quiet {
color: var(--muted);
background: color-mix(in srgb, var(--text) 10%, transparent);
text-transform: none;
letter-spacing: 0;
font-weight: 600;
}
/* Marked services read at a glance from across the booth. */
[data-module="service-times"] .st-row.st-issue { border-left-color: var(--warn); }
[data-module="service-times"] .st-row.st-replay { border-left-color: var(--accent); }
[data-module="service-times"] .st-row.st-skip {
border-left-color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
[data-module="service-times"] .st-row.st-skip .st-time {
text-decoration: line-through;
text-decoration-thickness: 2px;
}
[data-module="service-times"] .st-row.st-affected {
border-left-color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
}
[data-module="service-times"] .st-note {
flex: 1 0 100%;
font-size: 0.8em;
color: var(--muted);
overflow-wrap: anywhere;
}
/* ── messages ─────────────────────────────────────────────────────── */
[data-module="service-times"] .st-empty,
[data-module="service-times"] .st-updated {
margin: 4px 2px 0;
font-size: 0.8em;
color: var(--muted);
}
[data-module="service-times"] .st-warn {
margin: 6px 2px 0;
font-size: 0.8em;
color: var(--warn);
}
/* ── the mark editor ──────────────────────────────────────────────── */
[data-module="service-times"] .st-editor {
position: sticky;
bottom: 0;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border: 1px solid var(--accent);
border-radius: 8px;
background: var(--panel);
box-shadow: 0 6px 20px rgb(0 0 0 / 0.28);
}
[data-module="service-times"] .st-editor-title {
font-weight: 700;
font-size: 0.9em;
}
[data-module="service-times"] .st-editor-status {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
[data-module="service-times"] .st-pick {
font: inherit;
font-size: 0.8em;
font-weight: 600;
color: var(--text);
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 9px;
cursor: pointer;
}
[data-module="service-times"] .st-pick:hover {
border-color: var(--accent);
}
[data-module="service-times"] .st-pick.active,
[data-module="service-times"] .st-save {
color: var(--accent-text, var(--text));
background: var(--accent);
border-color: var(--accent);
}
[data-module="service-times"] .st-editor-field {
display: flex;
flex-direction: column;
gap: 2px;
}
[data-module="service-times"] .st-editor-label {
font-size: 0.72em;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
[data-module="service-times"] .st-editor-input {
font: inherit;
font-size: 0.85em;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 7px;
min-width: 0;
}
[data-module="service-times"] .st-editor-input:focus {
outline: none;
border-color: var(--accent);
}
[data-module="service-times"] .st-editor-actions {
display: flex;
gap: 5px;
justify-content: flex-end;
}