Compare commits

..
Author SHA1 Message Date
christian f542b34b6c Merge remote-tracking branch 'origin/main' into claude/service-times-module
# Conflicts:
#	README.md
2026-09-24 11:33:48 -04:00
christian a1ea9262c6 Merge pull request 'Band Lineup's docs move to where every other module's live' (#21) from claude/band-lineup-docs-tidy into main 2026-09-24 11:31:03 -04:00
christianandClaude Opus 5 3ca960baf6 Service Times 1.0.0: every campus's services, and what production can't use
Four campuses run different Sunday schedules and take their resi feed from one
of two broadcast origins. When a service goes wrong — a machine crashes, a feed
is unusable — the booth has to know which service at which campus can be
altered, and which campuses are riding on the one that broke. That lived in
people's heads and in messages.

The times come from the church website rather than from configuration, because
the site is what stays current when services change through the year. Each
campus page carries a "Times" block; the module reads it and re-reads it on a
timer. Two things on those pages are not services and are excluded on purpose:
the main-office hours in every page's footer, and the "Office Hours" block
Apollo Beach carries inside its own Times section. The parse is bounded to the
Times heading and stops at either.

A mark on a service — issue, replay, do not use, with a note, and for a resi
campus which of the origin's services it is taking — is the booth's shared
answer, so it lives in the module's server state and reaches every tile over
SSE. Per-tile settings would let two campuses disagree about whether the 9:30 is
going out. Marks are written to the data directory so a restart mid-morning
does not lose them, and they expire after 36 hours so last week's never greet a
new Sunday.

Campuses added by hand in admin are a stopgap for a site that has not caught up
yet: as soon as the website publishes that campus, the site's times take over
and the admin health line says the manual line can be removed.

Verified: the parser against all five live campus pages, and 31 unit tests plus
16 client checks, each proven to fail under mutation — both parse guards, the
merge rule, dependent recording, and three client behaviours. The suite is run
here under JavaScriptCore, since this machine has no Node.

Not verified here: the module guide's resilience checklist needs a running
ProdDash — upstream loss and recovery, two tiles side by side, and tile removal
leaving no timers behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:57:48 -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, order), default columns, and the photo folder. Tile settings: parallax,
names, mic labels, and birthday confetti with its intensity and a test names, mic labels, and birthday confetti with its intensity and a test
picker — columns are − / + in the title bar. 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 - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. 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;
}