From 48526934bbd2eaefc8d0d2873475e4c342f612d3 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 13 Sep 2026 12:58:12 -0400 Subject: [PATCH 1/2] pco-plan: per-service timing, ProPresenter cues, timers for the Timers module The service clock now belongs to one service at a time: a plan's services take turns by window (the next one takes over pre-roll before its start, once the previous one has ended or was due to end), and moving into the next window resets the service elapsed clock, the item timing and the countdown. A service starts at its scheduled time or, under the new "Service timing" admin group, when a chosen ProPresenter document goes live (scheduled time as the failsafe); it ends when the chosen end document goes live, or at the next service / plan end + 60 min. The pure logic lives in service-timing.js and matching.js (cue documents are matched like plan items, with a stricter 0.75 bar so the pre-service loop never ends a service) with service-timing.test.js covering windows, precedence and failsafes. GET /timers and /timers/stream offer the service, current-item and countdown timers per the module guide; GET /documents lists the documents the Now/Next feed has shown or listed for the new selects. Loopback calls use shell.port, with the old proddash.json guess only when the shell hands no port. Manifest: 1.4.0, proddash >=1.4.0, provides ["timers"]; Client ID wording, shorter labels, group help tightened, field help trimmed. Co-Authored-By: Claude Fable 5.1 --- modules/pco-plan/client.js | 51 +- modules/pco-plan/matching.js | 85 +++ modules/pco-plan/module.json | 78 ++- modules/pco-plan/server.js | 743 ++++++++++++++++++++---- modules/pco-plan/service-timing.js | 238 ++++++++ modules/pco-plan/service-timing.test.js | 229 ++++++++ 6 files changed, 1275 insertions(+), 149 deletions(-) create mode 100644 modules/pco-plan/matching.js create mode 100644 modules/pco-plan/service-timing.js create mode 100644 modules/pco-plan/service-timing.test.js diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index 8e88a70..9e8cdf7 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -209,36 +209,55 @@ export default function create({ root, moduleApi }) { renderClock(); } + /** + * The header clock follows the service the server says owns the clock + * (state.service: idle → running → stopped, by schedule or by the admin's + * ProPresenter document cues): a countdown before it, the running time + * while it runs (with ahead/behind when following ProPresenter), the final + * time once it has ended. + */ function renderClock() { const plan = state?.plan; const live = state?.live || {}; + const svc = state?.service || {}; clockLabelEl.textContent = ''; clockValueEl.textContent = ''; clockValueEl.className = 'pp-clock-value'; if (!plan || headEl.hidden) return; const now = serverNow(); - if (on('showRunningClock') && live.following && live.serviceStartedAt) { + const startedAt = svc.startedAt || live.serviceStartedAt || 0; + if (on('showRunningClock') && svc.phase === 'running' && startedAt) { clockLabelEl.textContent = 'Running'; - clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000); - // Ahead of / behind the plan: elapsed minus what the completed items were planned to take. - const done = state.items.filter((it) => live.history?.[it.id]?.endedAt); - const planned = done.reduce((sum, it) => sum + (it.length || 0), 0); - const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0); - const drift = actual - planned; - if (planned && Math.abs(drift) >= 30) { - clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`; - clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead'); + clockValueEl.textContent = fmtDur((now - startedAt) / 1000); + if (live.following) { + // Ahead of / behind the plan: elapsed minus what the completed items were planned to take. + const done = state.items.filter((it) => live.history?.[it.id]?.endedAt); + const planned = done.reduce((sum, it) => sum + (it.length || 0), 0); + const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0); + const drift = actual - planned; + if (planned && Math.abs(drift) >= 30) { + clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`; + clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead'); + } } return; } - if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) { - clockLabelEl.textContent = 'Starts in'; - clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000); + if (on('showRunningClock') && svc.phase === 'stopped' && startedAt && svc.endedAt) { + clockLabelEl.textContent = 'Ended'; + clockValueEl.textContent = fmtDur((svc.endedAt - startedAt) / 1000); return; } - if (on('showRunningClock') && plan.serviceStartsAt) { - clockLabelEl.textContent = 'Since start'; - clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000); + const startsAt = svc.startsAt || plan.serviceStartsAt || 0; + if (on('showCountdown') && startsAt > now) { + clockLabelEl.textContent = 'Starts in'; + clockValueEl.textContent = fmtDur((startsAt - now) / 1000); + return; + } + if (on('showRunningClock') && startsAt) { + // Past the scheduled start and not started: waiting for the admin's + // start document to go live in ProPresenter (or its failsafe). + clockLabelEl.textContent = 'Scheduled'; + clockValueEl.textContent = fmtClock(startsAt); } } diff --git a/modules/pco-plan/matching.js b/modules/pco-plan/matching.js new file mode 100644 index 0000000..0ed052b --- /dev/null +++ b/modules/pco-plan/matching.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Title matching for the PCO Plan module — the one answer to "is this + * ProPresenter document that Planning Center thing?", used twice: + * + * - mapping the document ProPresenter has live to a plan item (the + * highlight and the item runtimes), and + * - recognising the documents an admin picked as the service start / end + * cues (see service-timing.js). + * + * A ProPresenter name and a Planning Center title rarely agree letter for + * letter ("This Is The Day - [ Full ]" vs "This Is The Day"), so both sides + * are tidied the way propresenter-core.cleanItemName does (tags in brackets, + * leading date codes and dash separators dropped), lower-cased, stripped of + * punctuation and compared: + * + * 1.00 identical after tidying "Post-Service Loop" ≈ "Post Service Loop" + * 0.85 one contains the other "Welcome" ≈ "Welcome & Announcements" + * Dice 2·shared words ÷ all words "Pre Service Loop" vs "Post Service Loop" → 0.67 + * + * Filler words ("the", "of", "and"…) are ignored. The thresholds: + * + * MATCH_THRESHOLD 0.6 plan items — two of three words is enough, because + * the item that scores best *and* is next in the + * plan wins, so a near miss lands on the right row. + * TRIGGER_THRESHOLD 0.75 service cues — a cue starts or freezes the service + * clock, so one wrong word must not fire it: the + * pre-service loop (0.67 against "Post Service Loop") + * stays a non-match, while a renamed + * "Post-Service Loop", a tagged "Post Service Loop + * [Full]" (1.0) or a shortened "Post Service" (0.85) + * still trigger. + */ + +const MATCH_THRESHOLD = 0.6; +const TRIGGER_THRESHOLD = 0.75; + +/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */ +function cleanTitle(raw) { + const original = String(raw || '').trim(); + let s = original; + s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); + s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); + s = s.replace(/\s[-–—]+\s/g, ' '); + s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); + s = s.replace(/\s{2,}/g, ' ').trim(); + return s || original; +} + +function normalize(raw) { + return cleanTitle(raw) + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/[’'`]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']); +function tokens(norm) { + return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t))); +} + +/** 0..1 similarity between two titles. */ +function similarity(a, b) { + const na = normalize(a); + const nb = normalize(b); + if (!na || !nb) return 0; + if (na === nb) return 1; + if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85; + const ta = tokens(na); + const tb = tokens(nb); + if (!ta.size || !tb.size) return 0; + let common = 0; + for (const t of ta) if (tb.has(t)) common += 1; + return (2 * common) / (ta.size + tb.size); // Dice coefficient +} + +/** Does a live ProPresenter document count as the cue document an admin picked? */ +function isCue(liveName, cueDocument) { + return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD; +} + +module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue }; diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index 25537dd..bbab98a 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -1,9 +1,10 @@ { "id": "pco-plan", "name": "PCO Plan", - "version": "1.3.0", - "proddash": ">=1.3.0", - "description": "Order of service from Planning Center Services, following ProPresenter live", + "version": "1.4.0", + "proddash": ">=1.4.0", + "description": "Order of service from Planning Center Services", + "provides": ["timers"], "client": "client.js", "server": "server.js", "style": "style.css", @@ -12,13 +13,16 @@ "configGroups": { "Planning Center credentials": { "collapsed": "whenSet", - "help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers." + "help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers." + }, + "Service timing": { + "help": "A plan's services (9:00, 11:00…) take turns owning the service clock: the next one takes over Pre-roll minutes before its scheduled start, once the previous one has ended or was due to end, and the elapsed clock, item timing and countdown start afresh for it. A service starts at its scheduled time unless Service start is set to a ProPresenter document — then it starts the moment that document goes live in ProPresenter (the document that opens the service; from Pre-roll before the scheduled start), falling back to the scheduled time if the document has not appeared Start failsafe minutes after it. It ends when the End document goes live (the document that closes the service — a post-service loop, say), or, without one, when the next service takes over or an hour after the last one's scheduled end. Documents are matched by name the way plan items are, so a renamed \"Post-Service Loop\" still counts as \"Post Service Loop\". The document lists hold what the ProPresenter module has shown or listed since ProdDash started plus what is saved here; a saved name stays selectable even when ProPresenter is off." } }, "configSchema": { "appId": { "type": "string", - "label": "Application ID", + "label": "Client ID", "default": "", "group": "Planning Center credentials" }, @@ -30,7 +34,7 @@ }, "accessToken": { "type": "password", - "label": "OAuth 2.0 access token (instead of Application ID + Secret)", + "label": "OAuth 2.0 access token (instead of Client ID + Secret)", "default": "", "group": "Planning Center credentials" }, @@ -38,8 +42,7 @@ "type": "select", "label": "Service type", "default": "", - "optionsRoute": "/service-types", - "help": "Your Planning Center Services folders appear as groups in this list; pick the service type whose plans the tiles follow. Save the credentials first if the list is empty." + "optionsRoute": "/service-types" }, "planId": { "type": "select", @@ -47,18 +50,67 @@ "default": "", "options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }], "optionsRoute": "/plans", - "optionsDependsOn": "serviceTypeId", - "help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service." + "optionsDependsOn": "serviceTypeId" }, "pollSeconds": { "type": "number", - "label": "Re-read the plan every N seconds", + "label": "Refresh Interval (s)", "default": 30 }, "followProPresenter": { "type": "switch", - "label": "Follow the ProPresenter module's live item to highlight the current plan item and time it", + "label": "Follow ProPresenter Module", "default": true + }, + "preRollMinutes": { + "type": "number", + "label": "Pre-roll (min)", + "default": 30, + "group": "Service timing", + "help": "How long before a service's scheduled start the clock, item timing and countdown switch to it." + }, + "useDocumentTriggers": { + "type": "switch", + "label": "ProPresenter document triggers", + "default": false, + "group": "Service timing" + }, + "serviceStartTrigger": { + "type": "select", + "label": "Service start", + "default": "schedule", + "group": "Service timing", + "showWhen": "useDocumentTriggers", + "options": [ + { "value": "schedule", "label": "Scheduled service time" }, + { "value": "document", "label": "ProPresenter document" } + ] + }, + "serviceStartDocument": { + "type": "select", + "label": "Start document", + "default": "", + "group": "Service timing", + "showWhen": "useDocumentTriggers", + "optionsRoute": "/documents", + "help": "The document that goes live as the service begins." + }, + "startGraceMinutes": { + "type": "number", + "label": "Start failsafe (min)", + "default": 10, + "group": "Service timing", + "showWhen": "useDocumentTriggers", + "help": "Without the start document this long after the scheduled start, the service counts from the scheduled start anyway." + }, + "serviceEndDocument": { + "type": "select", + "label": "End document", + "default": "", + "group": "Service timing", + "showWhen": "useDocumentTriggers", + "optionsRoute": "/documents", + "help": "The document that goes live as the service ends; it freezes the service clock." } }, "instanceGroups": { @@ -72,7 +124,7 @@ "Show / hide": { "collapsed": true, "columns": 3, - "help": "Untick anything this tile should not show." + "help": "Switch off anything this tile should not show." } }, "instanceSchema": { diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js index 0b3c937..41d98d0 100644 --- a/modules/pco-plan/server.js +++ b/modules/pco-plan/server.js @@ -13,9 +13,13 @@ * GET /service-types → { options } for the admin "Service type" select, * grouped by Planning Center folder path * GET /plans?serviceTypeId=… → { options } for the admin "Plan" select + * GET /documents → { options } for the admin start / end document selects + * (documents ProPresenter has shown or lists — see below) + * GET /timers → snapshot for the Timers module (guide: "Offering + * GET /timers/stream → SSE, `timers` events timers to the Timers module") * * Planning Center API v2 (https://api.planningcenteronline.com/services/v2): - * Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0 + * Auth: Personal Access Token (HTTP Basic client_id:secret) or an OAuth 2.0 * bearer token. Responses are JSON:API — `data`, `included`, `links.next`. * GET /folders, /service_types (setup browsing) * GET /service_types/{st}/plans?filter=future|past (pick the plan) @@ -23,13 +27,19 @@ * GET …/plans/{id}/plan_times (service start times) * GET …/plans/{id}/items?include=song,arrangement,item_notes * - * Phase 2 — following ProPresenter: instead of talking to ProPresenter a - * second time, this module reads the propresenter-now-next module's own SSE - * feed over loopback (/api/modules/propresenter-now-next/stream) and matches - * the live presentation name to a plan item. Item start/end timestamps are - * tracked here, once, so every tile agrees on the runtime. The shell's port - * is guessed the way the shell decides it and then confirmed from the Host - * header of the first tile request. + * Following ProPresenter: instead of talking to ProPresenter a second time, + * this module reads the propresenter-now-next module's own SSE feed over + * loopback (/api/modules/propresenter-now-next/stream, on shell.port) and + * matches the live presentation name to a plan item. Item start/end + * timestamps are tracked here, once, so every tile agrees on the runtime. + * + * Service timing: a plan's services (9:00, 11:00…) take turns owning the + * service clock — the rules live in service-timing.js. A service starts at + * its scheduled time, or (admin "Service timing") when a chosen ProPresenter + * document goes live, and ends when the chosen end document goes live, with + * schedule failsafes for both. The result is offered to the Timers module as + * three timers: the service, the current item and the countdown to the + * service. * * PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at * tools/pco-mock.js for development. @@ -39,6 +49,16 @@ const http = require('http'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const { MATCH_THRESHOLD, similarity } = require('./matching'); +const { + MINUTE, + serviceTimes, + serviceWindows, + selectService, + freshService, + advance, + fmtTime, +} = require('./service-timing'); const DEFAULT_API_BASE = 'https://api.planningcenteronline.com'; const API_BASE = String(process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); @@ -56,8 +76,16 @@ const HEARTBEAT_MS = 15000; const NOW_NEXT_ID = 'propresenter-now-next'; const NOW_NEXT_RETRY_MS = 3000; const NOW_NEXT_MISSING_RETRY_MS = 15000; -/** Minimum name-similarity to call a ProPresenter document a plan item. */ -const MATCH_THRESHOLD = 0.6; +/** How many distinct documents seen live are remembered for the admin selects. */ +const SEEN_DOCUMENTS_MAX = 200; +/** + * Service timer tone (the Timers module colours by it): running behind the + * plan — completed items over their planned length, plus the current item's + * overrun — by BEHIND_WARN_MS is `warn`, by BEHIND_DANGER_MS `danger`; past + * the planned total length is `warn`, by more than BEHIND_DANGER_MS `danger`. + */ +const BEHIND_WARN_MS = 2 * MINUTE; +const BEHIND_DANGER_MS = 5 * MINUTE; /** Set by init(), read by the routes — both are rebuilt together on remount. */ let current = null; @@ -76,6 +104,8 @@ function clearedState() { plan: null, items: [], live: clearedLive(), + service: clearedService(), + timing: null, // the admin's service-timing choices (no secrets) }; } @@ -90,17 +120,39 @@ function clearedLive() { matched: false, // activeName maps to a plan item currentItemId: '', currentScore: 0, - serviceStartedAt: 0, // first matched item start, ms epoch + serviceStartedAt: 0, // the service's actual start, ms epoch (0 until it starts) + serviceEndedAt: 0, // …and its end, once it has one history: {}, // itemId → { startedAt, endedAt } }; } +/** The service that owns the clock, as tiles and the timers see it. */ +function clearedService() { + return { + id: '', + phase: 'none', // 'none' (no plan / no services) | 'idle' | 'running' | 'stopped' + startedAt: 0, + endedAt: 0, + startTrigger: '', + endTrigger: '', + cue: '', + name: '', + label: '', + index: -1, + count: 0, + startsAt: 0, + endsAt: 0, + windowFrom: 0, + windowUntil: 0, + }; +} + /** - * First guess at the shell's port, so the loopback Now/Next feed lands on this - * ProdDash before any browser has talked to us. Mirrors how the shell decides: + * Fallback for a shell that does not hand us its port (init() without + * `shell`, i.e. ProdDash before 1.4.0): guess the way the shell decides — * PORT env, then this machine's proddash.json in the data directory, then the - * checked-in config/proddash.json, then 24500. The guess is corrected from the - * Host header of the first request that reaches a route (see learnPort). + * checked-in config/proddash.json, then 24500 — and correct the guess from + * the Host header of the first request that reaches a route (see learnPort). */ function guessShellPort() { const env = Number(process.env.PORT); @@ -138,45 +190,15 @@ function localYmd(d) { return `${y}-${m}-${day}`; } -/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */ -function cleanTitle(raw) { - const original = String(raw || '').trim(); - let s = original; - s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); - s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); - s = s.replace(/\s[-–—]+\s/g, ' '); - s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); - s = s.replace(/\s{2,}/g, ' ').trim(); - return s || original; -} - -function normalize(raw) { - return cleanTitle(raw) - .toLowerCase() - .replace(/&/g, ' and ') - .replace(/[’'`]/g, '') - .replace(/[^a-z0-9]+/g, ' ') - .trim(); -} - -const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']); -function tokens(norm) { - return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t))); -} - -/** 0..1 similarity between two titles. */ -function similarity(a, b) { - const na = normalize(a); - const nb = normalize(b); - if (!na || !nb) return 0; - if (na === nb) return 1; - if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85; - const ta = tokens(na); - const tb = tokens(nb); - if (!ta.size || !tb.size) return 0; - let common = 0; - for (const t of ta) if (tb.has(t)) common += 1; - return (2 * common) / (ta.size + tb.size); // Dice coefficient +/** "1:02:03" / "4:05" for a duration in ms (for logs and status lines). */ +function fmtDur(ms) { + const s = Math.max(0, Math.round(Math.abs(Number(ms) || 0) / 1000)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return h + ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` + : `${m}:${String(sec).padStart(2, '0')}`; } /* ── Planning Center client ─────────────────────────────────────────── */ @@ -244,16 +266,6 @@ function createPcoClient(config) { /* ── plan shaping ───────────────────────────────────────────────────── */ -function pickServiceTime(planTimes, nowMs) { - const services = planTimes.filter((t) => t.timeType === 'service' && t.startsAt); - const pool = services.length ? services : planTimes.filter((t) => t.startsAt); - if (!pool.length) return null; - pool.sort((a, b) => a.startsAt - b.startsAt); - // The service that is running or up next; after the last one, the last one. - const upcoming = pool.find((t) => (t.endsAt || t.startsAt + 90 * 60000) > nowMs); - return upcoming || pool[pool.length - 1]; -} - /** Scheduled start of every item from the chosen service start time. */ function scheduleItems(items, serviceStartMs) { for (const item of items) item.startsAt = 0; @@ -280,6 +292,13 @@ function scheduleItems(items, serviceStartMs) { } } +/** Planned length of the service proper (the "during" items), ms. */ +function plannedLengthMs(items) { + return (items || []) + .filter((it) => it.servicePosition === 'during') + .reduce((sum, it) => sum + (Number(it.length) || 0) * 1000, 0); +} + function shapeItems(itemsBody) { const included = new Map(); for (const rec of itemsBody.included || []) included.set(`${rec.type}/${rec.id}`, rec); @@ -344,27 +363,72 @@ function shapePlan(planRec, serviceTypeId, planTimes) { }; } +/** "9:00 service" / "9:00 First Service" — how a service is named in details and logs. */ +function serviceLabel(win) { + if (!win || !win.startsAt) return ''; + const name = String(win.name || '').trim(); + return `${fmtTime(win.startsAt)} ${name && !/^service$/i.test(name) ? name : 'service'}`; +} + +/** What /timers answers while the module is not running. */ +function emptyTimers(now) { + return { source: 'pco-plan', label: 'PCO Plan', updatedAt: now, timers: [] }; +} + /* ── the module ─────────────────────────────────────────────────────── */ module.exports = { - init({ config, log }) { - const streams = new Set(); + init({ config, log, shell }) { + const streams = new Set(); // /stream clients (tiles) + const timerStreams = new Set(); // /timers/stream clients (the Timers module) const pco = createPcoClient(config); const serviceTypeId = String(config.serviceTypeId || '').trim(); const pinnedPlanId = String(config.planId || '').trim(); const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000; const follow = config.followProPresenter !== false; + // Service timing choices (admin "Service timing"). A blank number field + // reaches us as 0, which is a legitimate choice; an absent one as the + // schema default. + const minutes = (value, dflt, max) => { + const n = Number(value); + return Number.isFinite(n) && n >= 0 && value !== undefined && value !== null && value !== '' + ? Math.min(max, n) + : dflt; + }; + const triggersOn = Boolean(config.useDocumentTriggers); + const startDocument = triggersOn ? String(config.serviceStartDocument || '').trim() : ''; + const endDocument = triggersOn ? String(config.serviceEndDocument || '').trim() : ''; + const wantsDocumentStart = triggersOn && String(config.serviceStartTrigger || 'schedule') === 'document'; + const timing = { + preRollMs: minutes(config.preRollMinutes, 30, 240) * MINUTE, + startGraceMs: minutes(config.startGraceMinutes, 10, 120) * MINUTE, + triggersOn, + startMode: wantsDocumentStart && startDocument ? 'document' : 'schedule', + startDocument, + endDocument, + }; + const serviceOpts = { + startMode: timing.startMode, + startDocument: timing.startDocument, + endDocument: timing.endDocument, + startGraceMs: timing.startGraceMs, + }; + // Document cues need the ProPresenter feed even when the tiles don't follow it. + const wantFeed = follow || triggersOn; + let state = clearedState(); state.configured = pco.configured; state.serviceTypeSet = Boolean(serviceTypeId); state.serviceType = { id: serviceTypeId, name: '' }; state.live.following = follow; + state.timing = { ...timing }; let stopped = false; let pollTimer = null; let inFlight = null; let lastFrame = ''; + let lastTimersSig = ''; /* — broadcasting — */ @@ -372,19 +436,40 @@ module.exports = { return JSON.stringify(state); } + function writeAll(set, payload) { + for (const res of set) { + try { + res.write(payload); + } catch { + set.delete(res); + } + } + } + function broadcast(force) { const frame = frameOf(); if (!force && frame === lastFrame) return; lastFrame = frame; if (!streams.size) return; - const payload = `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`; - for (const res of streams) { - try { - res.write(payload); - } catch { - streams.delete(res); - } - } + writeAll(streams, `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`); + } + + /** + * The timers change "structurally" far less often than their displayed + * value does (the consumer extrapolates from startedAt), so a frame goes + * out only when something other than the running values changed. + */ + function timersSignature(snap) { + return JSON.stringify(snap.timers.map((t) => [t.id, t.state, t.startedAt, t.targetMs, t.status, t.detail])); + } + + function broadcastTimers(force) { + const snap = timersSnapshot(Date.now()); + const sig = timersSignature(snap); + if (!force && sig === lastTimersSig) return; + lastTimersSig = sig; + if (!timerStreams.size) return; + writeAll(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`); } /* — plan reading — */ @@ -474,32 +559,36 @@ module.exports = { } } if (!planRec) { + if (state.plan) log('no plan found any more — service timing reset'); state.plan = null; state.items = []; state.reachable = true; state.lastError = ''; state.lastFetched = Date.now(); + syncServiceSafe(Date.now(), null); return; } const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]); const plan = shapePlan(planRec, serviceTypeId, planTimes); - const service = pickServiceTime(planTimes, Date.now()); - if (service) { - plan.serviceStartsAt = service.startsAt; - plan.serviceEndsAt = service.endsAt; - plan.serviceName = service.name; - } - scheduleItems(items, plan.serviceStartsAt || plan.sortDate); - if (state.plan?.id !== plan.id) { - if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title})`); + const planChanged = state.plan?.id !== plan.id; + if (planChanged) { + if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title}) — service timing reset`); else log(`following plan ${plan.id}: ${plan.dates} ${plan.title || ''}`.trim()); - resetLiveTracking(); } state.plan = plan; state.items = items; state.reachable = true; state.lastError = ''; state.lastFetched = Date.now(); + if (planChanged) { + // A new plan: the next syncService picks its first window afresh + // (and resets the item timing while doing so). + win = null; + svc = freshService(null); + } + // Service selection, item schedule and the running clock all follow + // from the plan's service times — decided in one place. + syncServiceSafe(Date.now(), null); } function schedulePoll(ms) { @@ -523,6 +612,7 @@ module.exports = { log(`plan read failed: ${state.lastError}`); } broadcast(); + broadcastTimers(); schedulePoll(delay); })(); try { @@ -532,29 +622,318 @@ module.exports = { } } - /* — phase 2: follow the ProPresenter Now/Next feed over loopback — */ + /* — service timing: which service owns the clock, and its start / end — */ + + let windows = []; // serviceWindows() of the current plan + let win = null; // the window that owns the clock now + let svc = freshService(null); // its idle → running → stopped state (service-timing.js) + let lastTimingError = ''; + let countdownTargetId = ''; + let countdownSince = 0; + + /** A plan with no plan times still has a date — treat it as one service starting then. */ + function planServices(plan) { + if (serviceTimes(plan.planTimes).length) return plan.planTimes; + return plan.sortDate ? [{ id: 'plan', name: '', timeType: 'service', startsAt: plan.sortDate, endsAt: 0 }] : []; + } + + /** The plan's header times and every item's scheduled start follow the current service. */ + function applyServiceTimes() { + const plan = state.plan; + if (!plan) return; + plan.serviceStartsAt = win ? win.startsAt : 0; + plan.serviceEndsAt = win ? win.endsAt : 0; + plan.serviceName = win ? win.name : ''; + scheduleItems(state.items, plan.serviceStartsAt || plan.sortDate); + } + + /** state.service and the live mirror fields, from `win` + `svc`. */ + function mirrorService() { + const finite = (v) => (Number.isFinite(v) ? v : 0); + state.service = { + ...clearedService(), + ...svc, + name: win ? win.name : '', + label: serviceLabel(win), + index: win ? win.index : -1, + count: win ? win.count : 0, + startsAt: win ? win.startsAt : 0, + endsAt: win ? win.endsAt : 0, + windowFrom: win ? finite(win.windowFrom) : 0, + windowUntil: win ? finite(win.windowUntil) : 0, + }; + state.live.serviceStartedAt = svc.phase === 'running' || svc.phase === 'stopped' ? svc.startedAt : 0; + state.live.serviceEndedAt = svc.phase === 'stopped' ? svc.endedAt : 0; + } + + /** + * Bring the service timing up to `now`, optionally with a document that + * has just gone live in ProPresenter (`cue`). Selects the window that + * owns the clock (flipping — and resetting item timing — when the next + * service's window has opened), then advances that service's own + * idle → running → stopped state. Pure logic in service-timing.js. + */ + function syncService(now, cue) { + const plan = state.plan; + if (!plan) { + if (win || svc.phase !== 'none') { + win = null; + windows = []; + svc = freshService(null); + mirrorService(); + } + return; + } + const endedAt = svc.phase === 'stopped' && svc.endedAt ? { [svc.id]: svc.endedAt } : {}; + windows = serviceWindows(planServices(plan), { + preRollMs: timing.preRollMs, + plannedMs: plannedLengthMs(state.items), + endedAt, + }); + const next = selectService(windows, now); + if (!next) { + // A plan with neither service times nor a date: nothing to time. + if (win || svc.phase !== 'none') { + const hadWindow = Boolean(win); + win = null; + svc = freshService(null); + if (hadWindow) resetLiveTracking(); + applyServiceTimes(); + mirrorService(); + } + return; + } + if (!win || win.id !== next.id) { + // Leaving a window: a service still running is stopped at the boundary + // (the "next service's start" failsafe), then the new service takes + // over with fresh item timing. + if (win && svc.id === win.id && svc.phase === 'running') { + const leaving = windows.find((w) => w.id === win.id) || win; + const out = advance(svc, leaving, serviceOpts, Math.max(now, leaving.failsafeEndAt), null); + const endedAtMs = out.changed ? Math.min(out.svc.endedAt, now) : now; + log(`service ${serviceLabel(leaving)} ${out.changed ? out.event : 'left its window while running'} (ran ${fmtDur(endedAtMs - svc.startedAt)})`); + } + win = next; + svc = freshService(win); + log(`service window → ${serviceLabel(win)} (${win.index + 1} of ${win.count}; scheduled start ${fmtTime(win.startsAt)}, start by ${timing.startMode}${timing.endDocument ? ', end by document' : ''})`); + resetLiveTracking(); + applyServiceTimes(); + } else { + win = next; // refreshed fields — the plan's times may have been edited + if (plan.serviceStartsAt !== win.startsAt || plan.serviceEndsAt !== win.endsAt || plan.serviceName !== win.name) { + applyServiceTimes(); + } + } + const out = advance(svc, win, serviceOpts, now, cue); + if (out.changed) { + svc = out.svc; + log(`service ${serviceLabel(win)} ${out.event}`); + } + mirrorService(); + } + + /** syncService that can never take the plan display down with it. */ + function syncServiceSafe(now, cue) { + try { + syncService(now, cue); + } catch (err) { + const msg = err?.message || String(err); + if (msg !== lastTimingError) { + lastTimingError = msg; + log(`service timing error (plan display unaffected): ${msg}`); + } + } + } + + /* — timers offered to the Timers module — */ + + /** How far behind the plan the service runs, ms (negative = ahead). */ + function behindMs(now) { + const history = state.live.history || {}; + let drift = 0; + let any = false; + for (const it of state.items) { + const h = history[it.id]; + if (!h) continue; + if (h.endedAt) { + drift += (h.endedAt - h.startedAt) - (it.length || 0) * 1000; + any = true; + } else if (it.length) { + const over = (now - h.startedAt) - it.length * 1000; + if (over > 0) { + drift += over; + any = true; + } + } + } + return any ? drift : 0; + } + + function serviceTone(elapsedMs, plannedMs, behind) { + const over = plannedMs ? elapsedMs - plannedMs : 0; + if (over > BEHIND_DANGER_MS || behind >= BEHIND_DANGER_MS) return 'danger'; + if (over > 0 || behind >= BEHIND_WARN_MS) return 'warn'; + return 'ok'; + } + + /** The service the countdown counts to: the idle current one, or the next one once this one ended. */ + function countdownTarget() { + if (!win) return null; + if (svc.phase === 'idle') return win; + if (svc.phase === 'stopped') return windows[win.index + 1] || null; + return null; + } + + function timersSnapshot(now) { + const plan = state.plan; + const live = state.live; + const items = state.items.filter((it) => it.itemType !== 'header'); + const idx = live.currentItemId ? items.findIndex((it) => it.id === live.currentItemId) : -1; + const item = idx >= 0 ? items[idx] : null; + const hist = item ? live.history[item.id] : null; + const label = serviceLabel(win); + const itemDetail = item ? `Item ${idx + 1} of ${items.length}` : ''; + const itemTitle = item ? item.title || item.song?.title || '(untitled)' : ''; + const plannedMs = plannedLengthMs(state.items) + || (plan?.totalLength ? plan.totalLength * 1000 : 0) + || (win && win.endsAt > win.startsAt ? win.endsAt - win.startsAt : 0) + || null; + const timer = (id, name, kind) => ({ + id, + label: name, + kind, + state: 'idle', + startedAt: null, + elapsedMs: null, + remainingMs: null, + targetMs: null, + status: null, + detail: '', + }); + + // "Service" — elapsed since the service started, against its planned length. + const service = timer('service', 'Service', 'elapsed'); + service.targetMs = plannedMs; + if (!plan || !win) { + service.status = { text: plan ? 'No service times in this plan' : 'No plan', tone: 'muted' }; + } else if (svc.phase === 'running') { + const elapsed = now - svc.startedAt; + const behind = behindMs(now); + service.state = 'running'; + service.startedAt = svc.startedAt; + service.elapsedMs = elapsed; + service.status = { + text: itemTitle || (behind >= BEHIND_WARN_MS ? `Behind ${fmtDur(behind)}` : ''), + tone: serviceTone(elapsed, plannedMs, behind), + }; + service.detail = [label, itemDetail].filter(Boolean).join(' · '); + } else if (svc.phase === 'stopped') { + service.state = 'stopped'; + service.startedAt = svc.startedAt; + service.elapsedMs = svc.endedAt - svc.startedAt; + service.status = { text: `Ended ${fmtTime(svc.endedAt)}`, tone: 'muted' }; + service.detail = label; + } else { + const waiting = timing.startMode === 'document' && now >= win.startsAt; + service.status = waiting + ? { text: 'Waiting for the start document', tone: 'warn' } + : { text: `Starts ${fmtTime(win.startsAt)}`, tone: 'muted' }; + service.detail = label; + } + + // "Current item" — the live plan item's runtime against its planned length. + const currentItem = timer('item', 'Current item', 'elapsed'); + if (item && hist) { + const elapsed = (hist.endedAt || now) - hist.startedAt; + const planned = item.length ? item.length * 1000 : 0; + const over = planned ? elapsed - planned : 0; + currentItem.state = hist.endedAt ? 'stopped' : 'running'; + currentItem.startedAt = hist.startedAt; + currentItem.elapsedMs = elapsed; + currentItem.targetMs = planned || null; + currentItem.status = { + text: itemTitle, + tone: over > planned * 0.25 + 30000 ? 'danger' : over > 0 ? 'warn' : 'ok', + }; + currentItem.detail = itemDetail; + } else { + currentItem.status = { + text: !live.following ? 'Not following ProPresenter' : !live.available ? 'ProPresenter feed unavailable' : 'Nothing live', + tone: 'muted', + }; + } + + // "Countdown to service" — to the current service's start, then to the + // next one once this one has ended; frozen at zero while it runs; idle + // when today holds no further service. + const countdown = timer('countdown', 'Countdown to service', 'countdown'); + const target = countdownTarget(); + if (target && localYmd(new Date(target.startsAt)) === localYmd(new Date(now))) { + if (target.id !== countdownTargetId) { + countdownTargetId = target.id; + countdownSince = now; + } + const remaining = Math.max(0, target.startsAt - now); + countdown.detail = serviceLabel(target); + if (remaining > 0) { + countdown.state = 'running'; + countdown.startedAt = countdownSince; + countdown.targetMs = target.startsAt - countdownSince; + countdown.remainingMs = remaining; + } else { + countdown.state = 'stopped'; + countdown.remainingMs = 0; + countdown.status = { + text: timing.startMode === 'document' ? 'Waiting for the start document' : 'Service time', + tone: 'muted', + }; + } + } else if (win && svc.phase === 'running') { + countdown.state = 'stopped'; + countdown.remainingMs = 0; + countdown.detail = label; + } else { + countdown.status = { text: 'No upcoming service today', tone: 'muted' }; + } + + return { source: 'pco-plan', label: 'PCO Plan', updatedAt: now, timers: [service, currentItem, countdown] }; + } + + /* — the ProPresenter Now/Next feed over loopback — */ let nnRequest = null; let nnRetryTimer = null; let lastLiveName = null; - const guessedPort = guessShellPort(); + let lastView = null; // the latest Now/Next view-model (playlist items → document list) + const seenDocuments = new Map(); // document name → last seen live, ms + // The shell tells us its port (ProdDash ≥ 1.4.0). Without it, fall back to + // guessing and learning it from the first browser's Host header. + const shellPort = Number(shell?.port) || 0; + const guessedPort = shellPort || guessShellPort(); let nnPort = guessedPort; /** Learned ports that refused the loopback connection (a reverse proxy's port, say). */ const badPorts = new Set(); - /** A browser reached us on this Host — normally the port the shell really serves on. */ + /** Fallback only: a browser reached us on this Host — normally the shell's real port. */ function learnPort(hostHeader) { + if (shellPort) return; // the shell told us; nothing to learn const port = portFromHost(hostHeader); if (!port || port === nnPort || badPorts.has(port)) return; log(`ProdDash is on port ${port} (guessed ${nnPort}) — reconnecting the ProPresenter feed there`); nnPort = port; - if (!follow || stopped) return; + if (!wantFeed || stopped) return; clearTimeout(nnRetryTimer); try { nnRequest?.destroy(); } catch { /* gone */ } nnRequest = null; connectNowNext(); } + /** + * Forget the item timing (a new plan, or the next service's window). Only + * a *change* of document is a cue from here on — whatever is live at this + * moment (the previous service's loop, a document left up) must not start + * or end the new service — but the highlight may pick it straight back up. + */ function resetLiveTracking() { const keep = state.live; state.live = { @@ -566,7 +945,15 @@ module.exports = { ppReachable: keep.ppReachable, activeName: keep.activeName, }; - lastLiveName = null; + lastLiveName = keep.activeName || ''; + if (follow && keep.activeName) { + const hit = matchItem(keep.activeName); + state.live.matched = Boolean(hit); + if (hit) { + state.live.currentScore = hit.score; + startItem(hit.item.id); + } + } } /** Best plan item for a live presentation name, preferring the ones ahead of us. */ @@ -592,7 +979,54 @@ module.exports = { return best; } + /** Remember a document ProPresenter put live, most recent first, for the admin selects. */ + function rememberDocument(name) { + const n = String(name || '').trim(); + if (!n) return; + seenDocuments.delete(n); + seenDocuments.set(n, Date.now()); + while (seenDocuments.size > SEEN_DOCUMENTS_MAX) seenDocuments.delete(seenDocuments.keys().next().value); + } + + /** + * Options for the admin "Start document" / "End document" selects. The + * Now/Next module exposes the live document and the active playlist's + * items — not ProPresenter's library — so the list is: what is live now, + * the active playlist, every document seen live since ProdDash started, + * and whatever is saved (kept selectable even when ProPresenter doesn't + * show it right now). + */ + function documentOptions() { + const options = [{ value: '', label: 'None' }]; + const seen = new Set(['']); + const add = (name, group) => { + const n = String(name || '').trim(); + if (!n || seen.has(n)) return; + seen.add(n); + options.push({ value: n, label: n, group }); + }; + const live = state.live; + if (live.activeName) add(live.activeName, 'Live now'); + const playlist = Array.isArray(lastView?.items) ? lastView.items : []; + const playlistGroup = lastView?.playlistName ? `Playlist: ${lastView.playlistName}` : 'Active playlist'; + for (const it of playlist) { + if (!it || it.isHidden || it.type === 'header') continue; + add(it.name, playlistGroup); + } + const recent = [...seenDocuments.entries()].sort((a, b) => b[1] - a[1]); + for (const [name] of recent) add(name, 'Seen live since ProdDash started'); + for (const saved of [timing.startDocument, timing.endDocument]) { + if (saved && !seen.has(saved)) add(saved, 'Saved (not in ProPresenter right now)'); + } + let note = ''; + if (!wantFeed) note = 'Turn on ProPresenter document triggers (or Follow ProPresenter Module), save, then reopen this list.'; + else if (!live.available) note = `ProPresenter module feed unavailable — ${live.reason || 'reconnecting'}; showing what was seen earlier and what is saved.`; + else if (!live.ppReachable) note = 'ProPresenter is unreachable — showing what was seen earlier and what is saved.'; + return { options, note }; + } + function applyLiveView(view) { + lastView = view && typeof view === 'object' ? view : null; const live = state.live; live.available = true; live.reason = ''; @@ -605,17 +1039,27 @@ module.exports = { // Cleared output between items is normal — the current item stays current. lastLiveName = ''; broadcast(); + broadcastTimers(); return; } - if (name === lastLiveName) return; - lastLiveName = name; - const hit = matchItem(name); - live.matched = Boolean(hit); - if (hit) { - live.currentScore = hit.score; - if (hit.item.id !== live.currentItemId) startItem(hit.item.id); + if (name === lastLiveName) { + broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise + return; } + lastLiveName = name; + rememberDocument(name); + if (follow) { + const hit = matchItem(name); + live.matched = Boolean(hit); + if (hit) { + live.currentScore = hit.score; + if (hit.item.id !== live.currentItemId) startItem(hit.item.id); + } + } + // The same document is also a possible service start / end cue. + syncServiceSafe(Date.now(), name); broadcast(); + broadcastTimers(); } function startItem(itemId) { @@ -626,7 +1070,6 @@ module.exports = { } live.currentItemId = itemId; live.history[itemId] = { startedAt: now, endedAt: 0 }; - if (!live.serviceStartedAt) live.serviceStartedAt = now; const item = state.items.find((it) => it.id === itemId); log(`live item → ${item?.title || itemId}`); } @@ -638,6 +1081,7 @@ module.exports = { live.reason = reason; live.ppReachable = false; broadcast(); + broadcastTimers(); } if (stopped) return; clearTimeout(nnRetryTimer); @@ -646,7 +1090,7 @@ module.exports = { } function connectNowNext() { - if (stopped || !follow) return; + if (stopped || !wantFeed) return; const port = nnPort; const req = http.get( { host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } }, @@ -684,7 +1128,7 @@ module.exports = { ); req.on('error', (err) => { if (req !== nnRequest) return; // superseded by a reconnect on the learned port - if (port !== guessedPort) { + if (!shellPort && port !== guessedPort) { // The Host header lied (reverse proxy, port forward) — that port is // not this ProdDash. Go back to the guess and never trust it again. badPorts.add(port); @@ -700,7 +1144,7 @@ module.exports = { /* — start — */ if (!pco.configured) { - state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin'; + state.lastError = 'No Planning Center credentials — add a Client ID + Secret (or OAuth token) in /admin'; log(state.lastError); } else if (!serviceTypeId) { state.lastError = 'No service type selected — choose one under PCO Plan in /admin'; @@ -709,24 +1153,50 @@ module.exports = { log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`); schedulePoll(0); } - if (follow) { + log( + `service timing: pre-roll ${Math.round(timing.preRollMs / MINUTE)} min; start by ${ + timing.startMode === 'document' + ? `document "${timing.startDocument}" (failsafe ${Math.round(timing.startGraceMs / MINUTE)} min after the scheduled start)` + : 'the scheduled time' + }; end ${timing.endDocument ? `by document "${timing.endDocument}"` : 'at the next service / plan end'}` + ); + if (wantsDocumentStart && !startDocument) { + log('service start is set to "ProPresenter document" but no start document is chosen — starting by the scheduled time instead'); + } + if (wantFeed) { state.live.reason = 'Connecting to the ProPresenter module feed'; connectNowNext(); } + mirrorService(); const heartbeat = setInterval(() => { - for (const res of streams) { - try { - res.write(': ping\n\n'); - } catch { - streams.delete(res); - } - } + writeAll(streams, ': ping\n\n'); + writeAll(timerStreams, ': ping\n\n'); }, HEARTBEAT_MS); heartbeat.unref?.(); + // The clock moves services along on its own: scheduled starts, window + // flips, failsafes, the countdown reaching zero. Every step is guarded — + // timing can never take the plan display down. + const ticker = setInterval(() => { + if (stopped) return; + syncServiceSafe(Date.now(), null); + try { + broadcast(); + broadcastTimers(); + } catch (err) { + const msg = err?.message || String(err); + if (msg !== lastTimingError) { + lastTimingError = msg; + log(`broadcast error: ${msg}`); + } + } + }, 1000); + ticker.unref?.(); + current = { streams, + timerStreams, pco, getState: () => state, learnPort, @@ -736,19 +1206,24 @@ module.exports = { }, listPlans, pickPlan, + timersSnapshot, + documentOptions, }; return { stop() { stopped = true; clearInterval(heartbeat); + clearInterval(ticker); clearTimeout(pollTimer); clearTimeout(nnRetryTimer); try { nnRequest?.destroy(); } catch { /* gone */ } - for (const res of streams) { - try { res.end(); } catch { /* already gone */ } + for (const set of [streams, timerStreams]) { + for (const res of set) { + try { res.end(); } catch { /* already gone */ } + } + set.clear(); } - streams.clear(); if (current && current.streams === streams) current = null; }, health() { @@ -759,8 +1234,9 @@ module.exports = { } const name = state.serviceType.name || serviceTypeId; const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found'; - const live = follow ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : ''; - return { status: 'ok', message: `Following ${name}${plan}${live}` }; + const service = win ? ` · ${serviceLabel(win)} ${svc.phase}` : ''; + const live = wantFeed ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : ''; + return { status: 'ok', message: `Following ${name}${plan}${service}${live}` }; }, }; }, @@ -770,6 +1246,15 @@ module.exports = { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); }; + const sseHead = (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'); + }; return { 'GET /state': (req, res) => { @@ -779,13 +1264,7 @@ module.exports = { 'GET /stream': (req, res) => { current?.learnPort(req.headers.host); - 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'); + sseHead(res); const state = current ? current.getState() : clearedState(); res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`); if (!current) return void res.end(); @@ -803,6 +1282,30 @@ module.exports = { ); }, + /* — timers for the Timers module (module guide: "Offering timers") — */ + + 'GET /timers': (req, res) => { + const now = Date.now(); + json(res, 200, current ? current.timersSnapshot(now) : emptyTimers(now)); + }, + + 'GET /timers/stream': (req, res) => { + sseHead(res); + const now = Date.now(); + res.write(`event: timers\ndata: ${JSON.stringify(current ? current.timersSnapshot(now) : emptyTimers(now))}\n\n`); + if (!current) return void res.end(); + const { timerStreams } = current; + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + timerStreams.add(res); + req.on('close', () => timerStreams.delete(res)); + }, + + /** Options for the admin "Start document" / "End document" selects. */ + 'GET /documents': (req, res) => { + if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' }); + json(res, 200, current.documentOptions()); + }, + /** * Options for the admin page's "Service type" select: every service * type, grouped by its Planning Center folder path — so the folders diff --git a/modules/pco-plan/service-timing.js b/modules/pco-plan/service-timing.js new file mode 100644 index 0000000..d180e9e --- /dev/null +++ b/modules/pco-plan/service-timing.js @@ -0,0 +1,238 @@ +'use strict'; + +/** + * Service timing for the PCO Plan module — pure functions, no I/O, no + * timers. server.js feeds them the plan's service times, the documents + * ProPresenter puts live and the clock; service-timing.test.js calls them + * directly. Two questions are answered here: + * + * 1. Which of a plan's services owns the clock right now? → serviceWindows / selectService + * 2. When does that service count as started, and as ended? → advance + * + * ── Service windows ──────────────────────────────────────────────────── + * + * A plan can have several services (plan_times of type "service": 9:00 and + * 11:00, say). Only ONE owns the service-elapsed clock, the current-item + * timing and the countdown at any moment, and moving into the next + * service's window resets all three. Each service's window runs from the + * end of the previous one to a boundary the next service's pre-roll sets: + * + * boundary(i → i+1) = min( start[i+1], + * max( start[i+1] − preRoll, endOf[i] ) ) + * + * endOf[i] = the moment service i ended (its end cue fired), else its + * scheduled end (plan_time ends_at, else start + the planned + * length of the "during" items, else 90 min). + * + * In words: the next service takes over `preRoll` before its scheduled + * start (30 min by default; admin "Pre-roll"), but never before the current + * one has ended or was due to end, and never later than its own start. A + * service still running at the boundary is stopped there — that is the + * "next service's start" failsafe the end cue has. The first window opens at + * the beginning of time (before 9:00 the day belongs to the 9:00 service: + * countdown running, service idle); the last window never closes — the last + * service stays "stopped" until the plan itself changes (the next day's plan + * takes over through the plan picker). The last service also has a failsafe + * end: its scheduled end + LAST_SERVICE_OVERRUN_MS. + * + * ── Start and end of a service ───────────────────────────────────────── + * + * phase: idle → running → stopped, never backwards within one window. + * + * start, schedule mode (default) at the scheduled start; startedAt = it. + * start, document mode when the start document goes live + * (edge: it has to *become* live, from + * pre-roll before the scheduled start + * onwards — a rehearsal an hour earlier + * that shows the same document is not the + * service). Failsafe: if the document has + * not gone live by start + grace (admin + * "Start failsafe", 10 min default), the + * service starts anyway, counting from + * the scheduled start — the best estimate + * of when it really began. + * end, document when the end document goes live while + * the service is running (an end document + * live before the start — the previous + * service's loop — is ignored). Failsafe + * either way: the window boundary (see + * above), or, for the last service, its + * scheduled end + LAST_SERVICE_OVERRUN_MS. + * + * Cue documents are matched with matching.isCue (TRIGGER_THRESHOLD, see + * matching.js for the tolerance), so "Post-Service Loop" still ends the + * service when ProPresenter calls it "Post Service Loop". + */ + +const { similarity, TRIGGER_THRESHOLD } = require('./matching'); + +const MINUTE = 60000; +/** How long before a service's scheduled start the timers switch to it (admin "Pre-roll"). */ +const DEFAULT_PRE_ROLL_MS = 30 * MINUTE; +/** Document mode: start by the schedule anyway this long after the scheduled start (admin "Start failsafe"). */ +const DEFAULT_START_GRACE_MS = 10 * MINUTE; +/** The last service of a plan is stopped this long after its scheduled end if nothing ended it. */ +const LAST_SERVICE_OVERRUN_MS = 60 * MINUTE; +/** A service with no end time and no planned item lengths is assumed this long. */ +const DEFAULT_SERVICE_LENGTH_MS = 90 * MINUTE; + +/** The plan times that are services (every timed one when none is typed "service"), earliest first. */ +function serviceTimes(planTimes) { + const list = Array.isArray(planTimes) ? planTimes.filter((t) => t && Number(t.startsAt) > 0) : []; + const services = list.filter((t) => t.timeType === 'service'); + return (services.length ? services : list).slice().sort((a, b) => a.startsAt - b.startsAt); +} + +/** + * The window each service owns (see the file comment). + * + * @param {Array} planTimes [{ id, name, timeType, startsAt, endsAt }] (ms epochs) + * @param {object} [opts] + * @param {number} [opts.preRollMs] how early the next service may take over (default 30 min) + * @param {number} [opts.plannedMs] planned length of the service — for services without ends_at + * @param {object} [opts.endedAt] { [serviceId]: ms } services already ended by their cue + * @returns {Array<{ id, name, index, count, startsAt, endsAt, windowFrom, windowUntil, cueFrom, failsafeEndAt, isLast }>} + */ +function serviceWindows(planTimes, opts = {}) { + const preRoll = Number.isFinite(opts.preRollMs) ? Math.max(0, opts.preRollMs) : DEFAULT_PRE_ROLL_MS; + const planned = Number(opts.plannedMs) > 0 ? Number(opts.plannedMs) : 0; + const ended = opts.endedAt && typeof opts.endedAt === 'object' ? opts.endedAt : {}; + const services = serviceTimes(planTimes); + const windows = services.map((svc, i) => { + const scheduledEnd = svc.endsAt > svc.startsAt ? svc.endsAt : svc.startsAt + (planned || DEFAULT_SERVICE_LENGTH_MS); + const endOf = Number(ended[svc.id]) > 0 ? Number(ended[svc.id]) : scheduledEnd; + const next = services[i + 1]; + const windowUntil = next ? Math.min(next.startsAt, Math.max(next.startsAt - preRoll, endOf)) : Infinity; + return { + id: String(svc.id), + name: String(svc.name || ''), + index: i, + count: services.length, + startsAt: svc.startsAt, + endsAt: scheduledEnd, + windowFrom: -Infinity, // filled in below + windowUntil, + cueFrom: svc.startsAt - preRoll, // a start document counts from here on + failsafeEndAt: next ? windowUntil : scheduledEnd + LAST_SERVICE_OVERRUN_MS, + isLast: !next, + }; + }); + for (let i = 1; i < windows.length; i += 1) windows[i].windowFrom = windows[i - 1].windowUntil; + return windows; +} + +/** The window that holds `now` — null only when there are no services. */ +function selectService(windows, now) { + if (!Array.isArray(windows) || !windows.length) return null; + return windows.find((w) => now >= w.windowFrom && now < w.windowUntil) || windows[windows.length - 1]; +} + +/** Timing state for a service that has just come into its window. */ +function freshService(window) { + return { + id: window ? String(window.id) : '', + phase: window ? 'idle' : 'none', + startedAt: 0, + endedAt: 0, + startTrigger: '', // 'schedule' | 'document' | 'failsafe' + endTrigger: '', // 'document' | 'next-service' | 'plan-end' + cue: '', // the document that started / ended it + }; +} + +function fmtTime(ms) { + return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); +} + +/** + * Move a service's timing on by a clock tick or by a document going live. + * Pure: returns a new state and a one-line description of what happened + * (for the log), or `changed: false`. + * + * @param {object} svc state from freshService(window) / a previous advance() + * @param {object} window from serviceWindows() + * @param {object} opts { startMode: 'schedule'|'document', startDocument, endDocument, startGraceMs } + * @param {number} now ms epoch + * @param {string} [cue] the document that just went live (omit on a plain tick) + * @returns {{ svc: object, changed: boolean, event: string }} + */ +function advance(svc, window, opts, now, cue) { + const o = opts || {}; + const grace = Number.isFinite(o.startGraceMs) ? Math.max(0, o.startGraceMs) : DEFAULT_START_GRACE_MS; + const cueName = String(cue || '').trim(); + const same = { svc, changed: false, event: '' }; + if (!svc || !window || svc.id !== String(window.id)) return same; + const at = fmtTime(window.startsAt); + + if (svc.phase === 'idle') { + const documentMode = o.startMode === 'document' && o.startDocument; + if (documentMode) { + if (cueName && now >= window.cueFrom) { + const score = similarity(cueName, o.startDocument); + if (score >= TRIGGER_THRESHOLD) { + return { + svc: { ...svc, phase: 'running', startedAt: now, startTrigger: 'document', cue: cueName }, + changed: true, + event: `started at ${fmtTime(now)} — "${cueName}" went live (start document "${o.startDocument}", match ${score.toFixed(2)})`, + }; + } + } + if (now >= window.startsAt + grace) { + return { + svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'failsafe' }, + changed: true, + event: `started (failsafe) — the start document "${o.startDocument}" did not go live within ${Math.round(grace / MINUTE)} min of ${at}; counting from ${at}`, + }; + } + return same; + } + if (now >= window.startsAt) { + return { + svc: { ...svc, phase: 'running', startedAt: window.startsAt, startTrigger: 'schedule' }, + changed: true, + event: `started — scheduled ${at}`, + }; + } + return same; + } + + if (svc.phase === 'running') { + if (o.endDocument && cueName) { + const score = similarity(cueName, o.endDocument); + if (score >= TRIGGER_THRESHOLD) { + return { + svc: { ...svc, phase: 'stopped', endedAt: now, endTrigger: 'document', cue: cueName }, + changed: true, + event: `ended at ${fmtTime(now)} — "${cueName}" went live (end document "${o.endDocument}", match ${score.toFixed(2)})`, + }; + } + } + if (now >= window.failsafeEndAt) { + const endedAt = Math.min(now, window.failsafeEndAt); + return { + svc: { ...svc, phase: 'stopped', endedAt, endTrigger: window.isLast ? 'plan-end' : 'next-service' }, + changed: true, + event: window.isLast + ? `stopped (failsafe) — ${Math.round(LAST_SERVICE_OVERRUN_MS / MINUTE)} min past its scheduled end ${fmtTime(window.endsAt)}` + : `stopped (failsafe) — the next service's window opened at ${fmtTime(endedAt)}`, + }; + } + return same; + } + + return same; +} + +module.exports = { + MINUTE, + DEFAULT_PRE_ROLL_MS, + DEFAULT_START_GRACE_MS, + LAST_SERVICE_OVERRUN_MS, + DEFAULT_SERVICE_LENGTH_MS, + serviceTimes, + serviceWindows, + selectService, + freshService, + advance, + fmtTime, +}; diff --git a/modules/pco-plan/service-timing.test.js b/modules/pco-plan/service-timing.test.js new file mode 100644 index 0000000..8ee3479 --- /dev/null +++ b/modules/pco-plan/service-timing.test.js @@ -0,0 +1,229 @@ +'use strict'; + +/** + * Unit tests for the PCO Plan module's pure service-timing logic. + * + * node modules/pco-plan/service-timing.test.js + * + * Plain Node, no dependencies; exits non-zero on the first failure. Times are + * built for a two-service Sunday (9:00 and 11:00, 75 min each, 30 min + * pre-roll) so the expectations read like the day would. + */ + +const assert = require('assert'); +const { similarity, isCue, MATCH_THRESHOLD, TRIGGER_THRESHOLD } = require('./matching'); +const { + MINUTE, + LAST_SERVICE_OVERRUN_MS, + serviceWindows, + selectService, + freshService, + advance, +} = require('./service-timing'); + +const DAY = new Date(2026, 8, 13, 0, 0, 0, 0).getTime(); // a local midnight +const at = (h, m = 0) => DAY + (h * 60 + m) * MINUTE; +const hm = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + +const PLAN_TIMES = [ + { id: 'r', name: 'Rehearsal', timeType: 'rehearsal', startsAt: at(8, 0), endsAt: at(8, 45) }, + { id: 's1', name: 'First Service', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 15) }, + { id: 's2', name: 'Second Service', timeType: 'service', startsAt: at(11, 0), endsAt: at(12, 15) }, +]; +const PRE_ROLL = 30 * MINUTE; +const OPTS = { preRollMs: PRE_ROLL }; + +let passed = 0; +function test(name, fn) { + try { + fn(); + passed += 1; + console.log(` ok ${name}`); + } catch (err) { + console.log(` FAIL ${name}\n ${err.message}`); + process.exitCode = 1; + throw err; + } +} + +console.log('service windows'); + +test('rehearsal is not a service; windows are contiguous and ordered', () => { + const w = serviceWindows(PLAN_TIMES, OPTS); + assert.deepStrictEqual(w.map((x) => x.id), ['s1', 's2']); + assert.strictEqual(w[0].windowFrom, -Infinity); + assert.strictEqual(w[0].windowUntil, w[1].windowFrom); + assert.strictEqual(w[1].windowUntil, Infinity); + assert.strictEqual(w[0].count, 2); +}); + +test('the boundary is 30 min before the next start once the first was due to end', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + // 10:15 scheduled end, 11:00 − 30 = 10:30 → boundary 10:30 + assert.strictEqual(hm(w1.windowUntil), hm(at(10, 30))); + assert.strictEqual(w1.failsafeEndAt, w1.windowUntil, 'first service failsafe-stops at the boundary'); +}); + +test('a pre-roll larger than the gap waits for the scheduled end, never passes the next start', () => { + const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE }); + assert.strictEqual(hm(w1.windowUntil), hm(at(10, 15)), 'max(10:00, 10:15)'); + const [tight] = serviceWindows( + [ + { id: 'a', timeType: 'service', startsAt: at(9, 0), endsAt: at(10, 30) }, + { id: 'b', timeType: 'service', startsAt: at(10, 0), endsAt: at(11, 0) }, + ], + OPTS + ); + assert.strictEqual(hm(tight.windowUntil), hm(at(10, 0)), 'capped at the next start'); +}); + +test('an early actual end lets the next window open at pre-roll', () => { + const [w1] = serviceWindows(PLAN_TIMES, { preRollMs: 60 * MINUTE, endedAt: { s1: at(10, 5) } }); + assert.strictEqual(hm(w1.windowUntil), hm(at(10, 5)), 'ended at 10:05 inside the 10:00 pre-roll → flips at 10:05'); + const [w1b] = serviceWindows(PLAN_TIMES, { preRollMs: PRE_ROLL, endedAt: { s1: at(10, 5) } }); + assert.strictEqual(hm(w1b.windowUntil), hm(at(10, 30)), 'ended at 10:05 before the 10:30 pre-roll → flips at 10:30'); +}); + +test('a service without ends_at gets the planned length, else 90 min', () => { + const [w] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], { plannedMs: 70 * MINUTE }); + assert.strictEqual(hm(w.endsAt), hm(at(10, 10))); + const [w90] = serviceWindows([{ id: 'x', timeType: 'service', startsAt: at(9, 0), endsAt: 0 }], {}); + assert.strictEqual(hm(w90.endsAt), hm(at(10, 30))); + assert.strictEqual(w90.failsafeEndAt, w90.endsAt + LAST_SERVICE_OVERRUN_MS); +}); + +test('selectService: 8:50 → 9:00, 9:30 → 9:00, 10:55 → 11:00, 11:05 → 11:00', () => { + const w = serviceWindows(PLAN_TIMES, OPTS); + assert.strictEqual(selectService(w, at(8, 50)).id, 's1'); + assert.strictEqual(selectService(w, at(9, 30)).id, 's1'); + assert.strictEqual(selectService(w, at(10, 29)).id, 's1', 'still the first service until the 10:30 boundary'); + assert.strictEqual(selectService(w, at(10, 55)).id, 's2'); + assert.strictEqual(selectService(w, at(11, 5)).id, 's2'); + assert.strictEqual(selectService(w, at(6, 0)).id, 's1', 'the morning belongs to the first service'); + assert.strictEqual(selectService(w, at(15, 0)).id, 's2', 'the afternoon stays with the last service'); + assert.strictEqual(selectService([], at(9, 0)), null); +}); + +console.log('service start'); + +const SCHEDULE = { startMode: 'schedule', startDocument: '', endDocument: '', startGraceMs: 10 * MINUTE }; +const DOCUMENT = { + startMode: 'document', + startDocument: 'Welcome & Announcements', + endDocument: 'Post Service Loop', + startGraceMs: 10 * MINUTE, +}; + +test('schedule mode: idle before 9:00, running from 9:00 counting from 9:00; documents ignored', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + let s = freshService(w1); + assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59), 'Welcome').changed, false, 'a document is not a cue in schedule mode'); + assert.strictEqual(advance(s, w1, SCHEDULE, at(8, 59)).changed, false); + const out = advance(s, w1, SCHEDULE, at(9, 0, 30)); + assert.strictEqual(out.changed, true); + s = out.svc; + assert.strictEqual(s.phase, 'running'); + assert.strictEqual(s.startedAt, at(9, 0), 'counts from the scheduled start, not the tick that noticed'); + assert.strictEqual(s.startTrigger, 'schedule'); +}); + +test('document mode: the start document going live starts the service at that moment', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + const s = freshService(w1); + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 0, 30)).changed, false, 'the scheduled time alone does not start it'); + const out = advance(s, w1, DOCUMENT, at(8, 58), 'Welcome and Announcements [Full]'); + assert.strictEqual(out.changed, true, 'a renamed / tagged start document still counts'); + assert.strictEqual(out.svc.phase, 'running'); + assert.strictEqual(out.svc.startedAt, at(8, 58), 'an early start is the real start'); + assert.strictEqual(out.svc.startTrigger, 'document'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Post Service Loop').changed, false, 'the end document does not start an idle service'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 1), 'Goodness of God').changed, false, 'an unrelated document does nothing'); +}); + +test('document mode: the start document is only a cue from pre-roll before the start', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + const s = freshService(w1); + assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 0), 'Welcome & Announcements').changed, false, 'rehearsal at 8:00 shows it — not the service'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(8, 30), 'Welcome & Announcements').changed, true, 'from 8:30 it counts'); +}); + +test('document mode: failsafe at start + grace counts from the scheduled start', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + const s = freshService(w1); + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 9, 59)).changed, false); + const out = advance(s, w1, DOCUMENT, at(9, 10)); + assert.strictEqual(out.changed, true); + assert.strictEqual(out.svc.phase, 'running'); + assert.strictEqual(out.svc.startedAt, at(9, 0)); + assert.strictEqual(out.svc.startTrigger, 'failsafe'); + assert.match(out.event, /failsafe/); +}); + +test('document mode: a cue and the failsafe in the same tick — the cue wins', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + const out = advance(freshService(w1), w1, DOCUMENT, at(9, 12), 'Welcome & Announcements'); + assert.strictEqual(out.svc.startTrigger, 'document'); + assert.strictEqual(out.svc.startedAt, at(9, 12)); +}); + +console.log('service end'); + +test('the end document freezes a running service; a second start document is ignored', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + let s = advance(freshService(w1), w1, DOCUMENT, at(9, 0), 'Welcome & Announcements').svc; + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Welcome & Announcements').changed, false, 'shown again mid-service: nothing'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(9, 40), 'Pre Service Loop').changed, false, 'the PRE-service loop must not end it (0.67 < 0.75)'); + const out = advance(s, w1, DOCUMENT, at(10, 12), 'Post-Service Loop'); + assert.strictEqual(out.changed, true, 'a hyphenated end document still counts'); + s = out.svc; + assert.strictEqual(s.phase, 'stopped'); + assert.strictEqual(s.endedAt, at(10, 12)); + assert.strictEqual(s.endTrigger, 'document'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 20), 'Welcome & Announcements').changed, false, 'stopped stays stopped'); + assert.strictEqual(advance(s, w1, DOCUMENT, at(10, 45)).changed, false); +}); + +test('failsafe: a service still running when the next window opens stops at the boundary', () => { + const [w1] = serviceWindows(PLAN_TIMES, OPTS); + const s = advance(freshService(w1), w1, SCHEDULE, at(9, 0)).svc; + assert.strictEqual(advance(s, w1, SCHEDULE, at(10, 29)).changed, false, 'running past its 10:15 end is allowed'); + const out = advance(s, w1, SCHEDULE, at(10, 31)); + assert.strictEqual(out.changed, true); + assert.strictEqual(out.svc.phase, 'stopped'); + assert.strictEqual(hm(out.svc.endedAt), hm(at(10, 30)), 'frozen at the boundary, not at the tick'); + assert.strictEqual(out.svc.endTrigger, 'next-service'); +}); + +test('failsafe: the last service stops an hour after its scheduled end', () => { + const [, w2] = serviceWindows(PLAN_TIMES, OPTS); + const s = advance(freshService(w2), w2, SCHEDULE, at(11, 0)).svc; + assert.strictEqual(advance(s, w2, SCHEDULE, at(13, 14)).changed, false); + const out = advance(s, w2, SCHEDULE, at(13, 15)); + assert.strictEqual(out.changed, true); + assert.strictEqual(out.svc.phase, 'stopped'); + assert.strictEqual(hm(out.svc.endedAt), hm(at(13, 15))); + assert.strictEqual(out.svc.endTrigger, 'plan-end'); +}); + +test('the end document does not stop a service in the wrong window', () => { + const w = serviceWindows(PLAN_TIMES, OPTS); + const s1 = advance(freshService(w[0]), w[0], DOCUMENT, at(9, 0), 'Welcome & Announcements').svc; + assert.strictEqual(advance(s1, w[1], DOCUMENT, at(10, 40), 'Post Service Loop').changed, false, 'state and window must belong together'); +}); + +console.log('matching'); + +test('plan items: 0.6 — two of three words; cues: 0.75 — pre/post loops stay apart', () => { + assert.strictEqual(MATCH_THRESHOLD, 0.6); + assert.strictEqual(TRIGGER_THRESHOLD, 0.75); + assert.strictEqual(similarity('Post-Service Loop', 'Post Service Loop'), 1); + assert.strictEqual(similarity('Post Service Loop [Full]', 'Post Service Loop'), 1); + assert.strictEqual(similarity('Post Service', 'Post Service Loop'), 0.85); + assert.ok(Math.abs(similarity('Pre Service Loop', 'Post Service Loop') - 2 / 3) < 0.001); + assert.strictEqual(isCue('Pre Service Loop', 'Post Service Loop'), false); + assert.strictEqual(isCue('Post-Service Loop', 'Post Service Loop'), true); + assert.strictEqual(isCue('Welcome', 'Welcome & Announcements'), true); + assert.strictEqual(isCue('Anything', ''), false); +}); + +console.log(`\n${passed} tests passed`); From 820df909bfbc97cc0d835ff1db344fd05957f204 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Sun, 13 Sep 2026 13:12:04 -0400 Subject: [PATCH 2/2] pco-plan: timers say when a service began and when the live item is stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A running service with no live item (not following ProPresenter, nothing matched yet) now reports "Started 9:00 AM" instead of an empty status, and the current-item timer is muted and flagged "ProPresenter unreachable" while the feed or ProPresenter is down — it keeps counting the last item we knew of, but the wall can tell it is stale. Co-Authored-By: Claude Fable 5.1 --- modules/pco-plan/server.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js index 41d98d0..77f4852 100644 --- a/modules/pco-plan/server.js +++ b/modules/pco-plan/server.js @@ -822,8 +822,11 @@ module.exports = { service.state = 'running'; service.startedAt = svc.startedAt; service.elapsedMs = elapsed; + // The live item is the headline; without one (not following + // ProPresenter, nothing matched yet) say how far behind, else when + // the service began. service.status = { - text: itemTitle || (behind >= BEHIND_WARN_MS ? `Behind ${fmtDur(behind)}` : ''), + text: itemTitle || (behind >= BEHIND_WARN_MS ? `Behind ${fmtDur(behind)}` : `Started ${fmtTime(svc.startedAt)}`), tone: serviceTone(elapsed, plannedMs, behind), }; service.detail = [label, itemDetail].filter(Boolean).join(' · '); @@ -851,11 +854,14 @@ module.exports = { currentItem.startedAt = hist.startedAt; currentItem.elapsedMs = elapsed; currentItem.targetMs = planned || null; + // With ProPresenter (or its module) unreachable this is the last item + // we knew of — still counting, but muted so the wall says "stale". + const stale = !live.available || !live.ppReachable; currentItem.status = { text: itemTitle, - tone: over > planned * 0.25 + 30000 ? 'danger' : over > 0 ? 'warn' : 'ok', + tone: stale ? 'muted' : over > planned * 0.25 + 30000 ? 'danger' : over > 0 ? 'warn' : 'ok', }; - currentItem.detail = itemDetail; + currentItem.detail = stale ? `${itemDetail} · ProPresenter unreachable` : itemDetail; } else { currentItem.status = { text: !live.following ? 'Not following ProPresenter' : !live.available ? 'ProPresenter feed unavailable' : 'Nothing live',