diff --git a/README.md b/README.md
index 6f0f5bb..fc3e1fb 100644
--- a/README.md
+++ b/README.md
@@ -374,6 +374,23 @@ below. Nothing is installed without someone clicking.
(one mic/pack label per line, in stage order), default columns, and a
Google Drive folder for manual photos. Tile settings: names, mic labels,
parallax, and the birthday confetti with its intensity.
+- **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.
diff --git a/modules/service-times/client.js b/modules/service-times/client.js
new file mode 100644
index 0000000..1dd3ceb
--- /dev/null
+++ b/modules/service-times/client.js
@@ -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 = '';
+const ICON_CLEAR = '';
+const ICON_SMALLER = '';
+const ICON_BIGGER = '';
+
+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 = '';
+ },
+ };
+}
diff --git a/modules/service-times/module.json b/modules/service-times/module.json
new file mode 100644
index 0000000..7792310
--- /dev/null
+++ b/modules/service-times/module.json
@@ -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 /locations// 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" }
+ }
+}
diff --git a/modules/service-times/parse.js b/modules/service-times/parse.js
new file mode 100644
index 0000000..353a519
--- /dev/null
+++ b/modules/service-times/parse.js
@@ -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 9:30a 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(/([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 become line
+ * breaks so "7:30a 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(//g, ' ')
+ .replace(/ /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*]*>\s*get\s+involved\s*<\/\1\s*>/i.exec(rest)
+ || />\s*get\s+involved\s* {
+ 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,
+};
diff --git a/modules/service-times/parse.test.js b/modules/service-times/parse.test.js
new file mode 100644
index 0000000..05b7943
--- /dev/null
+++ b/modules/service-times/parse.test.js
@@ -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) => `
+
Address
1 Example Road
+
Times
+ ${inner}
+
+
Get Involved
Give now
+
+`;
+
+const WOONSOCKET = page('
Sunday
9:00a & 11:00a
');
+const NORTH_ATTLEBORO = page('
Sunday
7:30a 9:30a 11:30a
');
+const ORLANDO = page('
Sundays
Welcome 10:00am
Start 10:30am
');
+const SOUTHCOAST = page('
Sunday
10:30a
');
+const APOLLO_BEACH = page(
+ '
Sunday
'
+ + '
9:00a & 11:00a
'
+ + '
Office Hours
Mon-Wed 9:00a-4:30p
'
+);
+
+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 ', () => {
+ 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(
+ '
Sunday
9:00a & 11:00a
'
+ + '
Office Hours
Mon 9:00a
Thu 2:00p
'
+);
+
+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('
Redirected elsewhere
', { 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(' and block ends become line breaks', () => {
+ assert.deepStrictEqual(htmlToLines('