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(/&#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
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('

7:30a
9:30a

'), ['7:30a', '9:30a']); + assert.deepStrictEqual(htmlToLines('

9:00a & 11:00a

'), ['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 = `https://waterschurch.org/locations/ + https://waterschurch.org/locations/north-attleboro/ + https://waterschurch.org/locations/norwood/`; + 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`); diff --git a/modules/service-times/server.js b/modules/service-times/server.js new file mode 100644 index 0000000..730d911 --- /dev/null +++ b/modules/service-times/server.js @@ -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(' '), + })), + }); + }, + }; + }, +}; diff --git a/modules/service-times/style.css b/modules/service-times/style.css new file mode 100644 index 0000000..bde1afa --- /dev/null +++ b/modules/service-times/style.css @@ -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; +}