Merge branch 'claude/pco-plan-service-timers' into claude/proddash-app-tweaks
This commit is contained in:
@@ -209,17 +209,27 @@ 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);
|
||||
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);
|
||||
@@ -229,16 +239,25 @@ export default function create({ root, moduleApi }) {
|
||||
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) {
|
||||
if (on('showRunningClock') && svc.phase === 'stopped' && startedAt && svc.endedAt) {
|
||||
clockLabelEl.textContent = 'Ended';
|
||||
clockValueEl.textContent = fmtDur((svc.endedAt - startedAt) / 1000);
|
||||
return;
|
||||
}
|
||||
const startsAt = svc.startsAt || plan.serviceStartsAt || 0;
|
||||
if (on('showCountdown') && startsAt > now) {
|
||||
clockLabelEl.textContent = 'Starts in';
|
||||
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
|
||||
clockValueEl.textContent = fmtDur((startsAt - now) / 1000);
|
||||
return;
|
||||
}
|
||||
if (on('showRunningClock') && plan.serviceStartsAt) {
|
||||
clockLabelEl.textContent = 'Since start';
|
||||
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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": {
|
||||
|
||||
+620
-111
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
@@ -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`);
|
||||
Reference in New Issue
Block a user