diff --git a/README.md b/README.md
index 3cd53b3..ea8c71f 100644
--- a/README.md
+++ b/README.md
@@ -135,14 +135,25 @@ ignored from then on.
and pick a selection via the tile's "Timers ▾" menu; overruns are
unmistakably red. Read-only — no start/stop controls. Admin config:
ProPresenter endpoint (host : port), password.
+- **PCO Plan** — the order of service from Planning Center Services: every
+ plan item with its scheduled start time, length, type, song key,
+ description and item notes (each detail is a per-tile show/hide setting).
+ When the ProPresenter module is running it follows that module's live
+ item, highlights the matching plan item, dims what's done and shows the
+ elapsed vs planned runtime and how far ahead/behind the service is. Admin
+ config: Planning Center credentials (Personal Access Token app ID + secret,
+ or an OAuth token) and the service type — browse your Planning Center
+ folders and pick it at `/modules/pco-plan/setup.html`.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
To develop modules against mocks instead of real gear:
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
-`127.0.0.1:1599`) and `node tools/pp-timers-mock.js 1600` (ProPresenter
-timers + timecode on `127.0.0.1:1600`). See the testing section of the
+`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
+timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js`
+(Planning Center Services on `127.0.0.1:24700` — run ProdDash with
+`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the
[module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app
diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md
index 6741fa1..674c231 100644
--- a/docs/MODULE-GUIDE.md
+++ b/docs/MODULE-GUIDE.md
@@ -462,6 +462,15 @@ client-side failures (a tile that fails to start says so in the tile).
overrunning, stopped and elapsed timers plus an LTC timecode that
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`.
+- Planning Center: `node tools/pco-mock.js` (port 24700) — folders, service
+ types, plans (last week / today / next week), plan times and items with
+ songs, keys and item notes, behind a Basic/Bearer auth check. Planning
+ Center is a cloud API, so the module reads its base URL from the
+ environment instead of admin config: run ProdDash with
+ `PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any
+ Application ID + Secret in `/admin`, and pick "Sunday Service" in
+ `/modules/pco-plan/setup.html`. Its item titles match `pp-mock.js`, so
+ running both mocks exercises the ProPresenter matching.
**The resilience checklist** — every module must pass this before it's done:
diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js
new file mode 100644
index 0000000..0eb2666
--- /dev/null
+++ b/modules/pco-plan/client.js
@@ -0,0 +1,558 @@
+/* PCO Plan — client part.
+
+ The server part reads Planning Center and (optionally) follows the
+ ProPresenter module's live item; this tile only renders the plan it is
+ streamed. Which details show is a per-tile choice (gear menu). */
+
+const REFRESH_SVG = '';
+const SETUP_SVG = '';
+
+export default function create({ root, moduleApi }) {
+ let state = null;
+ let skew = 0; // server clock − local clock, ms
+ let feedOffline = false;
+ let stream = null;
+ let ticker = null;
+ let lastCurrentId = null;
+ let lastListKey = '';
+ let userScrolledAt = 0;
+
+ root.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+ const wrap = root.querySelector('.pp-wrap');
+ const headEl = root.querySelector('.pp-head');
+ const planTitleEl = root.querySelector('.pp-plan-title');
+ const planSubEl = root.querySelector('.pp-plan-sub');
+ const clockLabelEl = root.querySelector('.pp-clock-label');
+ const clockValueEl = root.querySelector('.pp-clock-value');
+ const listEl = root.querySelector('.pp-list');
+ const footEl = root.querySelector('.pp-foot');
+
+ const prefs = () => moduleApi.instanceSettings;
+ const serverNow = () => Date.now() + skew;
+
+ /* ── title-bar controls ─────────────────────────────────────────── */
+
+ const refreshBtn = moduleApi.header.addButton({
+ icon: REFRESH_SVG,
+ title: 'Re-read the plan from Planning Center now',
+ onClick() {
+ refreshBtn.classList.add('active');
+ moduleApi.fetch('/refresh', { method: 'POST' })
+ .catch(() => { /* the stream reports the outcome */ })
+ .finally(() => refreshBtn.classList.remove('active'));
+ },
+ });
+
+ function bumpTextSize(delta) {
+ const size = Number(prefs().textSize) || 14;
+ moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
+ applyPrefs();
+ }
+ moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
+ moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
+
+ moduleApi.header.addButton({
+ icon: SETUP_SVG,
+ title: 'Planning Center setup (choose the service type)',
+ onClick() {
+ window.open(`/modules/${moduleApi.id}/setup.html`, '_blank', 'noopener');
+ },
+ });
+
+ /* ── formatting ─────────────────────────────────────────────────── */
+
+ function fmtClock(ms) {
+ if (!ms) return '';
+ return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+ }
+
+ function fmtDur(totalSec, { signed = false } = {}) {
+ const sign = totalSec < 0 ? '-' : (signed ? '+' : '');
+ const s = Math.abs(Math.round(totalSec));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ if (h) return `${sign}${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
+ return `${sign}${m}:${String(sec).padStart(2, '0')}`;
+ }
+
+ function fmtMinutes(totalSec) {
+ const m = Math.round(totalSec / 60);
+ if (m < 60) return `${m} min`;
+ const h = Math.floor(m / 60);
+ const rest = m % 60;
+ return rest ? `${h} h ${rest} min` : `${h} h`;
+ }
+
+ function typeLabel(itemType) {
+ switch (itemType) {
+ case 'song': return 'Song';
+ case 'media': return 'Media';
+ case 'header': return '';
+ default: return '';
+ }
+ }
+
+ /* ── which items to show ────────────────────────────────────────── */
+
+ function visibleItems() {
+ const p = prefs();
+ const items = Array.isArray(state?.items) ? state.items : [];
+ return items.filter((it) => {
+ if (it.itemType === 'header' && !p.showHeaders) return false;
+ if (it.servicePosition === 'pre' && !p.showPreService) return false;
+ if (it.servicePosition === 'post' && !p.showPostService) return false;
+ return true;
+ });
+ }
+
+ function noteFilter() {
+ const raw = String(prefs().noteCategories || '');
+ const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
+ return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
+ }
+
+ /* ── status dot ─────────────────────────────────────────────────── */
+
+ function renderStatus() {
+ if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
+ if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
+ if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
+ if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — use the setup page');
+ if (!state.reachable) {
+ return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
+ }
+ const live = state.live || {};
+ let extra = '';
+ if (live.following) {
+ extra = live.available
+ ? (live.ppReachable ? ' · following ProPresenter' : ' · ProPresenter unreachable')
+ : ` · ${live.reason || 'ProPresenter feed unavailable'}`;
+ }
+ moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`);
+ }
+
+ /* ── plan header ────────────────────────────────────────────────── */
+
+ function renderHead() {
+ const plan = state?.plan;
+ const show = prefs().showPlanHeader && plan;
+ headEl.hidden = !show;
+ if (!show) return;
+ const title = [plan.dates, plan.title || plan.seriesTitle].filter(Boolean).join(' · ');
+ planTitleEl.textContent = title || (state.serviceType?.name || 'Plan');
+ const bits = [];
+ if (state.serviceType?.name) bits.push(state.serviceType.name);
+ if (plan.title && plan.seriesTitle) bits.push(plan.seriesTitle);
+ if (plan.serviceStartsAt) bits.push(`${plan.serviceName ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`);
+ const planned = state.items.filter((it) => it.servicePosition === 'during').reduce((sum, it) => sum + (it.length || 0), 0);
+ if (planned) bits.push(`planned ${fmtMinutes(planned)}`);
+ planSubEl.textContent = bits.join(' · ');
+ renderClock();
+ }
+
+ function renderClock() {
+ const plan = state?.plan;
+ const live = state?.live || {};
+ clockLabelEl.textContent = '';
+ clockValueEl.textContent = '';
+ clockValueEl.className = 'pp-clock-value';
+ if (!plan || headEl.hidden) return;
+ const now = serverNow();
+ if (prefs().showRuntime && live.serviceStartedAt) {
+ 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 planned = state.items
+ .filter((it) => live.history?.[it.id]?.endedAt)
+ .reduce((sum, it) => sum + (it.length || 0), 0);
+ const actual = state.items
+ .filter((it) => live.history?.[it.id]?.endedAt)
+ .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 (plan.serviceStartsAt && plan.serviceStartsAt > now) {
+ clockLabelEl.textContent = 'Starts in';
+ clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
+ return;
+ }
+ if (plan.serviceStartsAt) {
+ clockLabelEl.textContent = 'Since start';
+ clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
+ }
+ }
+
+ /* ── the list ───────────────────────────────────────────────────── */
+
+ function emptyMessage() {
+ if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
+ if (!state) return { title: 'Connecting…', hint: '' };
+ if (!state.configured) {
+ return {
+ title: 'Planning Center not connected',
+ hint: 'Add a Planning Center Application ID and Secret (Personal Access Token) for PCO Plan in Admin.',
+ link: { href: '/admin', text: 'Open Admin' },
+ };
+ }
+ if (!state.serviceTypeSet) {
+ return {
+ title: 'No service type selected',
+ hint: 'Browse your Planning Center folders and choose the service type to follow.',
+ link: { href: `/modules/${moduleApi.id}/setup.html`, text: 'Open setup' },
+ };
+ }
+ if (!state.reachable && !state.plan) {
+ return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
+ }
+ if (!state.plan) {
+ return {
+ title: 'No plan found',
+ hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
+ };
+ }
+ if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings.' };
+ return null;
+ }
+
+ function buildRow(item, p, notesWanted) {
+ const row = document.createElement('div');
+ row.className = 'pp-row';
+ row.dataset.id = item.id;
+ if (item.itemType === 'header') row.classList.add('is-header');
+ if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`);
+
+ const timeCol = document.createElement('div');
+ timeCol.className = 'pp-col-time';
+ timeCol.textContent = p.showStartTimes && item.startsAt && item.itemType !== 'header' ? fmtClock(item.startsAt) : '';
+ timeCol.hidden = !p.showStartTimes;
+ row.appendChild(timeCol);
+
+ const main = document.createElement('div');
+ main.className = 'pp-col-main';
+ const titleLine = document.createElement('div');
+ titleLine.className = 'pp-title-line';
+ const titleEl = document.createElement('span');
+ titleEl.className = 'pp-title';
+ titleEl.textContent = item.title || item.song?.title || '(untitled)';
+ titleLine.appendChild(titleEl);
+ if (item.itemType !== 'header') {
+ const nowTag = document.createElement('span');
+ nowTag.className = 'pp-now-tag';
+ nowTag.textContent = 'Now';
+ titleLine.appendChild(nowTag);
+ if (p.showItemType && typeLabel(item.itemType)) {
+ const badge = document.createElement('span');
+ badge.className = `pp-badge is-${item.itemType}`;
+ badge.textContent = typeLabel(item.itemType);
+ titleLine.appendChild(badge);
+ }
+ if (p.showKey && item.keyName) {
+ const key = document.createElement('span');
+ key.className = 'pp-key';
+ key.textContent = item.keyName;
+ titleLine.appendChild(key);
+ }
+ if (p.showArrangement && item.arrangement) {
+ const arr = document.createElement('span');
+ arr.className = 'pp-arr';
+ arr.textContent = item.arrangement;
+ titleLine.appendChild(arr);
+ }
+ }
+ main.appendChild(titleLine);
+
+ if (p.showDescription && item.description) {
+ const desc = document.createElement('div');
+ desc.className = 'pp-desc';
+ desc.textContent = item.description;
+ main.appendChild(desc);
+ }
+ if (p.showNotes && item.notes?.length) {
+ const notes = item.notes.filter(notesWanted);
+ if (notes.length) {
+ const notesEl = document.createElement('div');
+ notesEl.className = 'pp-notes';
+ for (const note of notes) {
+ const n = document.createElement('div');
+ n.className = 'pp-note';
+ if (note.category) {
+ const cat = document.createElement('span');
+ cat.className = 'pp-note-cat';
+ cat.textContent = note.category;
+ n.appendChild(cat);
+ }
+ n.appendChild(document.createTextNode(note.content));
+ notesEl.appendChild(n);
+ }
+ main.appendChild(notesEl);
+ }
+ }
+ row.appendChild(main);
+
+ const lenCol = document.createElement('div');
+ lenCol.className = 'pp-col-len';
+ if (item.itemType !== 'header') {
+ if (p.showLength) {
+ const len = document.createElement('div');
+ len.className = 'pp-len';
+ len.textContent = item.length ? fmtDur(item.length) : '';
+ lenCol.appendChild(len);
+ }
+ if (p.showRuntime && state?.live?.following) {
+ const el = document.createElement('div');
+ el.className = 'pp-elapsed';
+ lenCol.appendChild(el);
+ }
+ }
+ lenCol.hidden = !p.showLength && !(p.showRuntime && state?.live?.following);
+ row.appendChild(lenCol);
+ return row;
+ }
+
+ function renderList() {
+ const empty = emptyMessage();
+ if (empty) {
+ listEl.innerHTML = '';
+ lastListKey = '';
+ const box = document.createElement('div');
+ box.className = 'pp-empty';
+ const t = document.createElement('div');
+ t.className = 'pp-empty-title';
+ t.textContent = empty.title;
+ box.appendChild(t);
+ if (empty.hint) {
+ const h = document.createElement('div');
+ h.className = 'pp-empty-hint';
+ h.textContent = empty.hint;
+ box.appendChild(h);
+ }
+ if (empty.link) {
+ const a = document.createElement('a');
+ a.className = 'pp-empty-link';
+ a.href = empty.link.href;
+ a.target = '_blank';
+ a.rel = 'noopener';
+ a.textContent = empty.link.text;
+ box.appendChild(a);
+ }
+ listEl.appendChild(box);
+ return;
+ }
+
+ // Rebuild rows only when the plan content or the display prefs changed;
+ // live highlighting and timers are applied in place.
+ const p = prefs();
+ const key = JSON.stringify([state.plan.id, state.items, p, state.live?.following]);
+ if (key !== lastListKey) {
+ lastListKey = key;
+ listEl.innerHTML = '';
+ const notesWanted = noteFilter();
+ for (const item of visibleItems()) listEl.appendChild(buildRow(item, p, notesWanted));
+ lastCurrentId = null; // force the highlight + scroll pass
+ }
+ renderLiveMarks();
+ }
+
+ /** Current / done classes and the elapsed timers, without rebuilding rows. */
+ function renderLiveMarks() {
+ const live = state?.live || {};
+ const p = prefs();
+ const now = serverNow();
+ const currentId = live.following ? live.currentItemId || '' : '';
+ const history = live.history || {};
+ const byId = new Map(state.items.map((it) => [it.id, it]));
+ const rows = listEl.querySelectorAll('.pp-row');
+ // Everything before the current item counts as done, matched or not.
+ let seenCurrent = !currentId;
+ const doneIds = new Set();
+ if (currentId) {
+ for (const it of state.items) {
+ if (it.id === currentId) break;
+ if (it.itemType !== 'header') doneIds.add(it.id);
+ }
+ }
+ for (const row of rows) {
+ const id = row.dataset.id;
+ const item = byId.get(id);
+ const isCurrent = id === currentId;
+ row.classList.toggle('is-current', isCurrent);
+ row.classList.toggle('is-done', p.dimCompleted && doneIds.has(id));
+ if (isCurrent) seenCurrent = true;
+ const elapsedEl = row.querySelector('.pp-elapsed');
+ if (!elapsedEl || !item) continue;
+ const h = history[id];
+ elapsedEl.className = 'pp-elapsed';
+ if (!h) {
+ elapsedEl.textContent = '';
+ continue;
+ }
+ const elapsedSec = ((h.endedAt || now) - h.startedAt) / 1000;
+ elapsedEl.textContent = fmtDur(elapsedSec);
+ if (item.length) {
+ const over = elapsedSec - item.length;
+ if (over > item.length * 0.25 + 30) elapsedEl.classList.add('is-over-hard');
+ else if (over > 0) elapsedEl.classList.add('is-over');
+ }
+ if (!h.endedAt) elapsedEl.classList.add('is-running');
+ }
+ void seenCurrent;
+ if (currentId !== lastCurrentId) {
+ lastCurrentId = currentId;
+ if (currentId && p.autoScroll && Date.now() - userScrolledAt > 8000) {
+ const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
+ row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
+ }
+ }
+ }
+
+ /* ── footer: what ProPresenter is doing ─────────────────────────── */
+
+ function renderFoot() {
+ const live = state?.live;
+ const show = prefs().showLiveFooter && live?.following && state?.plan;
+ footEl.hidden = !show;
+ footEl.className = 'pp-foot';
+ if (!show) return;
+ if (!live.available) {
+ footEl.classList.add('is-muted');
+ footEl.textContent = live.reason || 'ProPresenter feed unavailable';
+ return;
+ }
+ if (!live.ppEnabled) {
+ footEl.classList.add('is-muted');
+ footEl.textContent = 'ProPresenter module has no host configured';
+ return;
+ }
+ if (!live.ppReachable) {
+ footEl.classList.add('is-down');
+ footEl.textContent = 'ProPresenter unreachable';
+ return;
+ }
+ if (!live.activeName) {
+ footEl.classList.add('is-muted');
+ footEl.textContent = 'Nothing live in ProPresenter';
+ return;
+ }
+ if (live.matched) {
+ footEl.classList.add('is-live');
+ footEl.textContent = `Live: ${live.activeName}`;
+ } else {
+ footEl.classList.add('is-unmatched');
+ footEl.textContent = `Live: ${live.activeName} — not in the plan`;
+ }
+ }
+
+ /* ── render + ticking ───────────────────────────────────────────── */
+
+ function render() {
+ wrap.classList.toggle('is-offline', feedOffline);
+ renderStatus();
+ if (!state) {
+ headEl.hidden = true;
+ renderList();
+ footEl.hidden = true;
+ return;
+ }
+ renderHead();
+ renderList();
+ renderFoot();
+ }
+
+ function applyPrefs() {
+ const size = Math.min(40, Math.max(9, Number(prefs().textSize) || 14));
+ wrap.style.setProperty('--pp-size', `${size}px`);
+ lastListKey = '';
+ render();
+ }
+
+ function tick() {
+ if (!state?.plan) return;
+ renderClock();
+ if (state.live?.following && prefs().showRuntime) renderLiveMarks();
+ }
+
+ /* ── live feed ──────────────────────────────────────────────────── */
+
+ function accept(payload) {
+ if (!payload || typeof payload !== 'object' || !payload.state) return;
+ state = payload.state;
+ if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
+ feedOffline = false;
+ render();
+ }
+
+ function connect() {
+ stream = moduleApi.sse('/stream', {
+ open() {
+ feedOffline = false;
+ render();
+ },
+ error() {
+ feedOffline = true;
+ render();
+ },
+ events: {
+ state(ev) {
+ try {
+ accept(JSON.parse(ev.data));
+ } catch { /* a bad frame is not fatal */ }
+ },
+ },
+ });
+ }
+
+ function onUserScroll() {
+ userScrolledAt = Date.now();
+ }
+
+ /* ── lifecycle ──────────────────────────────────────────────────── */
+
+ return {
+ start() {
+ applyPrefs();
+ moduleApi.setStatus('connecting', 'Connecting…');
+ moduleApi.fetch('/state')
+ .then((res) => (res.ok ? res.json() : null))
+ .then((body) => { if (body && !state) accept(body); })
+ .catch(() => { /* the stream will cover it */ });
+ connect();
+ listEl.addEventListener('wheel', onUserScroll, { passive: true });
+ listEl.addEventListener('touchmove', onUserScroll, { passive: true });
+ ticker = setInterval(tick, 1000);
+ },
+ stop() {
+ clearInterval(ticker);
+ ticker = null;
+ stream?.close();
+ stream = null;
+ listEl.removeEventListener('wheel', onUserScroll);
+ listEl.removeEventListener('touchmove', onUserScroll);
+ root.innerHTML = '';
+ },
+ onConfigChange() {
+ // The server re-inits and its stream re-sends the state; nothing to cache here.
+ lastListKey = '';
+ render();
+ },
+ };
+}
diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json
new file mode 100644
index 0000000..7384df4
--- /dev/null
+++ b/modules/pco-plan/module.json
@@ -0,0 +1,67 @@
+{
+ "id": "pco-plan",
+ "name": "PCO Plan",
+ "version": "1.0.0",
+ "description": "Order of service from Planning Center Services, following ProPresenter live. Pick the service type at /modules/pco-plan/setup.html",
+ "client": "client.js",
+ "server": "server.js",
+ "style": "style.css",
+ "minSize": { "w": 3, "h": 3 },
+ "defaultSize": { "w": 5, "h": 6 },
+ "configSchema": {
+ "appId": {
+ "type": "string",
+ "label": "Planning Center Application ID (Personal Access Token)",
+ "default": ""
+ },
+ "secret": {
+ "type": "password",
+ "label": "Planning Center Secret (Personal Access Token)",
+ "default": ""
+ },
+ "accessToken": {
+ "type": "password",
+ "label": "OAuth 2.0 access token (alternative to App ID + Secret)",
+ "default": ""
+ },
+ "serviceTypeId": {
+ "type": "string",
+ "label": "Service type ID — browse folders and pick one at /modules/pco-plan/setup.html",
+ "default": ""
+ },
+ "planId": {
+ "type": "string",
+ "label": "Pin a plan ID (blank = today's plan, else the next upcoming)",
+ "default": ""
+ },
+ "pollSeconds": {
+ "type": "number",
+ "label": "Re-read the plan every N seconds",
+ "default": 30
+ },
+ "followProPresenter": {
+ "type": "boolean",
+ "label": "Follow the ProPresenter module's live item to highlight the current plan item",
+ "default": true
+ }
+ },
+ "instanceSchema": {
+ "showPlanHeader": { "type": "boolean", "label": "Show plan title, date and service time", "default": true },
+ "showStartTimes": { "type": "boolean", "label": "Show scheduled start time of each item", "default": true },
+ "showLength": { "type": "boolean", "label": "Show planned length of each item", "default": true },
+ "showItemType": { "type": "boolean", "label": "Show item type badge (song / media)", "default": true },
+ "showKey": { "type": "boolean", "label": "Show song key", "default": true },
+ "showArrangement": { "type": "boolean", "label": "Show song arrangement", "default": false },
+ "showDescription": { "type": "boolean", "label": "Show item description", "default": true },
+ "showNotes": { "type": "boolean", "label": "Show item notes", "default": true },
+ "noteCategories": { "type": "string", "label": "Only these note categories (comma-separated, blank = all)", "default": "" },
+ "showHeaders": { "type": "boolean", "label": "Show section headers", "default": true },
+ "showPreService": { "type": "boolean", "label": "Show pre-service items", "default": true },
+ "showPostService": { "type": "boolean", "label": "Show post-service items", "default": true },
+ "showRuntime": { "type": "boolean", "label": "Show live runtime (elapsed vs planned) when following ProPresenter", "default": true },
+ "showLiveFooter": { "type": "boolean", "label": "Show the ProPresenter match line at the bottom", "default": true },
+ "dimCompleted": { "type": "boolean", "label": "Dim items that are already done", "default": true },
+ "autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
+ "textSize": { "type": "number", "label": "Text size (px)", "default": 14 }
+ }
+}
diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js
new file mode 100644
index 0000000..8871acd
--- /dev/null
+++ b/modules/pco-plan/server.js
@@ -0,0 +1,825 @@
+'use strict';
+
+/**
+ * PCO Plan — server part.
+ *
+ * Owns the one Planning Center Services connection, keeps the current plan
+ * (order of service) for the configured service type in memory, and fans it
+ * out to every tile over SSE. Browsers never talk to Planning Center.
+ *
+ * GET /state → { state, now } (instant paint for new tiles)
+ * GET /stream → SSE, `state` events (change-detected, heartbeated)
+ * POST /refresh → re-read the plan now
+ * GET /browse → folders + service types, for the setup page
+ * GET /preview?serviceTypeId=… → which plan the module would pick
+ *
+ * Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
+ * Auth: Personal Access Token (HTTP Basic app_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)
+ * GET /service_types/{st}/plans/{id} (title, dates…)
+ * 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.
+ *
+ * PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at
+ * tools/pco-mock.js for development.
+ */
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
+const API_BASE = String(process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
+const SERVICES = '/services/v2';
+
+/** Abort a hung Planning Center request well before the poll cadence. */
+const REQUEST_TIMEOUT_MS = 12000;
+/** Fastest allowed plan re-read (PCO rate limit is 100 requests / 20 s per app). */
+const MIN_POLL_S = 10;
+/** Backoff after a failed plan read. */
+const RETRY_MS = 15000;
+/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
+const HEARTBEAT_MS = 15000;
+/** The Now/Next module whose feed we follow. */
+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;
+
+/** Set by init(), read by the routes — both are rebuilt together on remount. */
+let current = null;
+
+/* ── small helpers ──────────────────────────────────────────────────── */
+
+function clearedState() {
+ return {
+ configured: false, // credentials present
+ serviceTypeSet: false, // a service type is chosen
+ reachable: false, // last Planning Center read succeeded
+ lastError: '',
+ lastFetched: 0,
+ apiBase: API_BASE,
+ serviceType: { id: '', name: '' },
+ plan: null,
+ items: [],
+ live: clearedLive(),
+ };
+}
+
+function clearedLive() {
+ return {
+ following: false, // admin switch on
+ available: false, // Now/Next feed reachable
+ reason: '', // why not available
+ ppEnabled: false,
+ ppReachable: false,
+ activeName: '', // what ProPresenter has live right now
+ matched: false, // activeName maps to a plan item
+ currentItemId: '',
+ currentScore: 0,
+ serviceStartedAt: 0, // first matched item start, ms epoch
+ history: {}, // itemId → { startedAt, endedAt }
+ };
+}
+
+/** The shell's port, so the loopback Now/Next feed lands on this ProdDash. */
+function shellPort() {
+ const env = Number(process.env.PORT);
+ if (env) return env;
+ try {
+ const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config', 'proddash.json'), 'utf8'));
+ if (Number(cfg.port)) return Number(cfg.port);
+ } catch { /* fall through */ }
+ return 24500;
+}
+
+function localYmd(d) {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ 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
+}
+
+/* ── Planning Center client ─────────────────────────────────────────── */
+
+function createPcoClient(config) {
+ const token = String(config.accessToken || '').trim();
+ const appId = String(config.appId || '').trim();
+ const secret = String(config.secret || '');
+ let auth = '';
+ if (token) auth = `Bearer ${token}`;
+ else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
+
+ async function get(target, params) {
+ const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
+ for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
+ let response;
+ try {
+ response = await fetch(url, {
+ headers: { Authorization: auth, Accept: 'application/json' },
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ } catch (err) {
+ const e = new Error(`Planning Center unreachable: ${err?.message || err}`);
+ e.network = true;
+ throw e;
+ }
+ if (!response.ok) {
+ let detail = '';
+ try {
+ const body = await response.json();
+ detail = body?.errors?.[0]?.detail || body?.errors?.[0]?.title || '';
+ } catch { /* no JSON body */ }
+ const e = new Error(
+ response.status === 401
+ ? 'Planning Center rejected the credentials (401)'
+ : `Planning Center ${response.status}${detail ? `: ${detail}` : ''}`
+ );
+ e.status = response.status;
+ if (response.status === 429) e.retryAfter = Number(response.headers.get('retry-after')) || 10;
+ throw e;
+ }
+ return response.json();
+ }
+
+ /** Follow JSON:API `links.next` until the collection is complete. */
+ async function getAll(target, params) {
+ const data = [];
+ const included = [];
+ let url = target;
+ let query = { per_page: 100, ...(params || {}) };
+ for (let page = 0; page < 30; page += 1) {
+ const body = await get(url, query);
+ if (Array.isArray(body?.data)) data.push(...body.data);
+ if (Array.isArray(body?.included)) included.push(...body.included);
+ const next = body?.links?.next;
+ if (!next) break;
+ url = next;
+ query = {}; // `next` already carries the query string
+ }
+ return { data, included };
+ }
+
+ return { configured: Boolean(auth), method: token ? 'oauth' : 'pat', get, getAll };
+}
+
+/* ── 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;
+ if (!serviceStartMs) return;
+ let t = serviceStartMs;
+ for (const item of items) {
+ if (item.servicePosition !== 'during') continue;
+ item.startsAt = t;
+ t += (item.length || 0) * 1000;
+ }
+ // Post-service items run on from the end of the service…
+ for (const item of items) {
+ if (item.servicePosition !== 'post') continue;
+ item.startsAt = t;
+ t += (item.length || 0) * 1000;
+ }
+ // …and pre-service items are counted backwards to end at the service start.
+ let back = serviceStartMs;
+ for (let i = items.length - 1; i >= 0; i -= 1) {
+ const item = items[i];
+ if (item.servicePosition !== 'pre') continue;
+ back -= (item.length || 0) * 1000;
+ item.startsAt = back;
+ }
+}
+
+function shapeItems(itemsBody) {
+ const included = new Map();
+ for (const rec of itemsBody.included || []) included.set(`${rec.type}/${rec.id}`, rec);
+ const rel = (item, name) => item?.relationships?.[name]?.data;
+ const items = (itemsBody.data || []).map((it) => {
+ const a = it.attributes || {};
+ const songRef = rel(it, 'song');
+ const song = songRef ? included.get(`Song/${songRef.id}`) : null;
+ const arrRef = rel(it, 'arrangement');
+ const arrangement = arrRef ? included.get(`Arrangement/${arrRef.id}`) : null;
+ const noteRefs = Array.isArray(rel(it, 'item_notes')) ? rel(it, 'item_notes') : [];
+ const notes = noteRefs
+ .map((ref) => included.get(`ItemNote/${ref.id}`))
+ .filter(Boolean)
+ .map((n) => ({
+ category: String(n.attributes?.category_name || ''),
+ content: String(n.attributes?.content || '').trim(),
+ }))
+ .filter((n) => n.content);
+ return {
+ id: String(it.id),
+ sequence: Number(a.sequence) || 0,
+ title: String(a.title || '').trim(),
+ itemType: String(a.item_type || 'item'),
+ length: Number(a.length) || 0,
+ servicePosition: String(a.service_position || 'during'),
+ description: String(a.description || '').trim(),
+ keyName: String(a.key_name || '').trim(),
+ song: song
+ ? {
+ title: String(song.attributes?.title || ''),
+ author: String(song.attributes?.author || ''),
+ ccli: song.attributes?.ccli_number ? String(song.attributes.ccli_number) : '',
+ }
+ : null,
+ arrangement: arrangement ? String(arrangement.attributes?.name || '') : '',
+ notes,
+ startsAt: 0,
+ };
+ });
+ items.sort((x, y) => x.sequence - y.sequence);
+ return items;
+}
+
+function shapePlan(planRec, serviceTypeId, planTimes) {
+ const a = planRec?.attributes || {};
+ return {
+ id: String(planRec?.id || ''),
+ serviceTypeId: String(serviceTypeId),
+ title: String(a.title || '').trim(),
+ seriesTitle: String(a.series_title || '').trim(),
+ dates: String(a.dates || '').trim(),
+ shortDates: String(a.short_dates || '').trim(),
+ sortDate: a.sort_date ? Date.parse(a.sort_date) || 0 : 0,
+ totalLength: Number(a.total_length) || 0,
+ itemsCount: Number(a.items_count) || 0,
+ url: String(a.planning_center_url || ''),
+ planTimes,
+ serviceStartsAt: 0,
+ serviceEndsAt: 0,
+ serviceName: '',
+ };
+}
+
+/* ── the module ─────────────────────────────────────────────────────── */
+
+module.exports = {
+ init({ config, log }) {
+ const streams = new Set();
+ 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;
+
+ let state = clearedState();
+ state.configured = pco.configured;
+ state.serviceTypeSet = Boolean(serviceTypeId);
+ state.serviceType = { id: serviceTypeId, name: '' };
+ state.live.following = follow;
+
+ let stopped = false;
+ let pollTimer = null;
+ let inFlight = null;
+ let lastFrame = '';
+
+ /* — broadcasting — */
+
+ function frameOf() {
+ return JSON.stringify(state);
+ }
+
+ 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);
+ }
+ }
+ }
+
+ /* — plan reading — */
+
+ function planPath(planId) {
+ return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
+ }
+
+ /** Today's plan, else the next upcoming one, else the most recent past one. */
+ async function choosePlanId(stId) {
+ const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
+ const [future, past] = await Promise.all([
+ pco.get(base, { filter: 'future', order: 'sort_date', per_page: 5 }),
+ pco.get(base, { filter: 'past', order: '-sort_date', per_page: 3 }),
+ ]);
+ const byId = new Map();
+ for (const rec of [...(future?.data || []), ...(past?.data || [])]) {
+ const sortDate = Date.parse(rec?.attributes?.sort_date || '');
+ if (!rec?.id || !Number.isFinite(sortDate)) continue;
+ byId.set(String(rec.id), { rec, sortDate });
+ }
+ const plans = Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
+ if (!plans.length) return null;
+ const now = Date.now();
+ const today = localYmd(new Date(now));
+ const todays = plans.find((p) => localYmd(new Date(p.sortDate)) === today);
+ if (todays) return todays.rec;
+ const next = plans.find((p) => p.sortDate > now);
+ if (next) return next.rec;
+ return plans[plans.length - 1].rec;
+ }
+
+ async function readPlanTimes(planId) {
+ const body = await pco.getAll(`${planPath(planId)}/plan_times`);
+ return (body.data || [])
+ .map((t) => ({
+ id: String(t.id),
+ name: String(t.attributes?.name || '').trim(),
+ timeType: String(t.attributes?.time_type || 'service'),
+ startsAt: Date.parse(t.attributes?.starts_at || '') || 0,
+ endsAt: Date.parse(t.attributes?.ends_at || '') || 0,
+ }))
+ .filter((t) => t.startsAt)
+ .sort((x, y) => x.startsAt - y.startsAt);
+ }
+
+ async function readItems(planId) {
+ const target = `${planPath(planId)}/items`;
+ try {
+ return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
+ } catch (err) {
+ // An account/API that refuses one of the includes still has items.
+ if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
+ log(`items include rejected (${err.message}) — reading items without includes`);
+ return shapeItems(await pco.getAll(target));
+ }
+ throw err;
+ }
+ }
+
+ async function readPlan() {
+ if (!pco.configured || !serviceTypeId) return;
+ let planRec = null;
+ if (pinnedPlanId) {
+ planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null;
+ } else {
+ const chosen = await choosePlanId(serviceTypeId);
+ // Re-read the chosen plan by id: the list endpoint's attributes are the
+ // same, but this keeps one code path and picks up edits immediately.
+ planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null;
+ }
+ if (!state.serviceType.name) {
+ try {
+ const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`);
+ state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') };
+ } catch (err) {
+ if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`);
+ throw err;
+ }
+ }
+ if (!planRec) {
+ state.plan = null;
+ state.items = [];
+ state.reachable = true;
+ state.lastError = '';
+ state.lastFetched = Date.now();
+ 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})`);
+ 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();
+ }
+
+ function schedulePoll(ms) {
+ if (stopped) return;
+ clearTimeout(pollTimer);
+ pollTimer = setTimeout(poll, ms);
+ pollTimer.unref?.();
+ }
+
+ async function poll() {
+ if (stopped) return;
+ if (inFlight) return inFlight;
+ inFlight = (async () => {
+ let delay = pollMs;
+ try {
+ await readPlan();
+ } catch (err) {
+ state.reachable = false;
+ state.lastError = err?.message || String(err);
+ delay = err?.retryAfter ? (err.retryAfter + 1) * 1000 : RETRY_MS;
+ log(`plan read failed: ${state.lastError}`);
+ }
+ broadcast();
+ schedulePoll(delay);
+ })();
+ try {
+ await inFlight;
+ } finally {
+ inFlight = null;
+ }
+ }
+
+ /* — phase 2: follow the ProPresenter Now/Next feed over loopback — */
+
+ let nnRequest = null;
+ let nnRetryTimer = null;
+ let lastLiveName = null;
+
+ function resetLiveTracking() {
+ const keep = state.live;
+ state.live = {
+ ...clearedLive(),
+ following: keep.following,
+ available: keep.available,
+ reason: keep.reason,
+ ppEnabled: keep.ppEnabled,
+ ppReachable: keep.ppReachable,
+ activeName: keep.activeName,
+ };
+ lastLiveName = null;
+ }
+
+ /** Best plan item for a live presentation name, preferring the ones ahead of us. */
+ function matchItem(name) {
+ const items = state.items.filter((it) => it.itemType !== 'header');
+ if (!items.length) return null;
+ const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId);
+ let best = null;
+ for (let i = 0; i < items.length; i += 1) {
+ const item = items[i];
+ const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
+ if (score < MATCH_THRESHOLD) continue;
+ // Distance ahead of the current item (wrapping so items behind rank last).
+ const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
+ if (
+ !best
+ || score > best.score + 0.001
+ || (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
+ ) {
+ best = { item, score, dist };
+ }
+ }
+ return best;
+ }
+
+ function applyLiveView(view) {
+ const live = state.live;
+ live.available = true;
+ live.reason = '';
+ live.ppEnabled = Boolean(view?.enabled);
+ live.ppReachable = Boolean(view?.reachable);
+ const activeItem = Array.isArray(view?.items) && view.activeIndex >= 0 ? view.items[view.activeIndex] : null;
+ const name = String(activeItem?.name || view?.activePresentationName || '').trim();
+ live.activeName = name;
+ if (!name) {
+ // Cleared output between items is normal — the current item stays current.
+ lastLiveName = '';
+ broadcast();
+ 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);
+ }
+ broadcast();
+ }
+
+ function startItem(itemId) {
+ const live = state.live;
+ const now = Date.now();
+ if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
+ live.history[live.currentItemId].endedAt = now;
+ }
+ 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}`);
+ }
+
+ function nowNextUnavailable(reason, retryMs) {
+ const live = state.live;
+ if (live.available || live.reason !== reason) {
+ live.available = false;
+ live.reason = reason;
+ live.ppReachable = false;
+ broadcast();
+ }
+ if (stopped) return;
+ clearTimeout(nnRetryTimer);
+ nnRetryTimer = setTimeout(connectNowNext, retryMs);
+ nnRetryTimer.unref?.();
+ }
+
+ function connectNowNext() {
+ if (stopped || !follow) return;
+ const port = shellPort();
+ const req = http.get(
+ { host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } },
+ (res) => {
+ if (res.statusCode !== 200) {
+ res.resume();
+ const why = res.statusCode === 404
+ ? 'ProPresenter module is disabled or not installed'
+ : `ProPresenter module feed answered ${res.statusCode}`;
+ return nowNextUnavailable(why, NOW_NEXT_MISSING_RETRY_MS);
+ }
+ res.setEncoding('utf8');
+ let buffer = '';
+ res.on('data', (chunk) => {
+ buffer += chunk;
+ let idx;
+ while ((idx = buffer.indexOf('\n\n')) >= 0) {
+ const block = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 2);
+ let event = 'message';
+ let data = '';
+ for (const line of block.split('\n')) {
+ if (line.startsWith('event:')) event = line.slice(6).trim();
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
+ }
+ if (event !== 'state' || !data) continue;
+ try {
+ applyLiveView(JSON.parse(data));
+ } catch { /* a bad frame is not fatal */ }
+ }
+ });
+ res.on('end', () => nowNextUnavailable('ProPresenter module feed ended — reconnecting', NOW_NEXT_RETRY_MS));
+ res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS));
+ }
+ );
+ req.on('error', (err) => nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS));
+ req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
+ nnRequest = req;
+ }
+
+ /* — start — */
+
+ if (!pco.configured) {
+ state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin';
+ log(state.lastError);
+ } else if (!serviceTypeId) {
+ state.lastError = 'No service type selected — open /modules/pco-plan/setup.html';
+ log(state.lastError);
+ } else {
+ log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
+ schedulePoll(0);
+ }
+ if (follow) {
+ state.live.reason = 'Connecting to the ProPresenter module feed';
+ connectNowNext();
+ }
+
+ const heartbeat = setInterval(() => {
+ for (const res of streams) {
+ try {
+ res.write(': ping\n\n');
+ } catch {
+ streams.delete(res);
+ }
+ }
+ }, HEARTBEAT_MS);
+ heartbeat.unref?.();
+
+ current = {
+ streams,
+ pco,
+ getState: () => state,
+ refresh() {
+ clearTimeout(pollTimer);
+ return poll();
+ },
+ choosePlanId,
+ };
+
+ return {
+ stop() {
+ stopped = true;
+ clearInterval(heartbeat);
+ clearTimeout(pollTimer);
+ clearTimeout(nnRetryTimer);
+ try { nnRequest?.destroy(); } catch { /* gone */ }
+ for (const res of streams) {
+ try { res.end(); } catch { /* already gone */ }
+ }
+ streams.clear();
+ if (current && current.streams === streams) current = null;
+ },
+ health() {
+ if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
+ if (!serviceTypeId) return { status: 'error', message: 'No service type selected — open /modules/pco-plan/setup.html' };
+ if (!state.reachable) {
+ return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
+ }
+ 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}` };
+ },
+ };
+ },
+
+ routes({ log }) {
+ const json = (res, status, body) => {
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(body));
+ };
+
+ return {
+ 'GET /state': (req, res) => {
+ json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
+ },
+
+ 'GET /stream': (req, res) => {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ 'Cache-Control': 'no-cache, no-transform',
+ Connection: 'keep-alive',
+ 'X-Accel-Buffering': 'no',
+ });
+ res.write('retry: 3000\n\n');
+ 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();
+ const { streams } = current;
+ try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
+ streams.add(res);
+ req.on('close', () => streams.delete(res));
+ },
+
+ 'POST /refresh': (req, res) => {
+ if (!current) return json(res, 503, { error: 'Module not running.' });
+ current.refresh().then(
+ () => json(res, 200, { ok: true, state: current?.getState() }),
+ (err) => json(res, 502, { error: err?.message || String(err) })
+ );
+ },
+
+ /** Folders + service types as flat lists; the setup page builds the tree. */
+ 'GET /browse': async (req, res) => {
+ if (!current) return json(res, 503, { error: 'Module not running.' });
+ const { pco } = current;
+ if (!pco.configured) {
+ return json(res, 400, { error: 'No Planning Center credentials — add them in /admin first.', unconfigured: true });
+ }
+ try {
+ const [folderBody, stBody] = await Promise.all([
+ pco.getAll(`${SERVICES}/folders`),
+ pco.getAll(`${SERVICES}/service_types`),
+ ]);
+ const folders = new Map();
+ for (const f of folderBody.data || []) {
+ folders.set(String(f.id), {
+ id: String(f.id),
+ name: String(f.attributes?.name || 'Folder'),
+ parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '',
+ });
+ }
+ const serviceTypes = (stBody.data || []).map((st) => ({
+ id: String(st.id),
+ name: String(st.attributes?.name || 'Service type'),
+ frequency: String(st.attributes?.frequency || ''),
+ parentId: st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '',
+ }));
+ // Fill in any folder the list did not include (some accounts only
+ // list root folders) by fetching the missing parents individually.
+ const missing = new Set();
+ for (const st of serviceTypes) if (st.parentId && !folders.has(st.parentId)) missing.add(st.parentId);
+ for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) missing.add(f.parentId);
+ let guard = 0;
+ while (missing.size && guard < 50) {
+ const id = missing.values().next().value;
+ missing.delete(id);
+ guard += 1;
+ try {
+ const one = (await pco.get(`${SERVICES}/folders/${encodeURIComponent(id)}`))?.data;
+ const parentId = one?.relationships?.parent?.data?.id ? String(one.relationships.parent.data.id) : '';
+ folders.set(id, { id, name: String(one?.attributes?.name || 'Folder'), parentId });
+ if (parentId && !folders.has(parentId)) missing.add(parentId);
+ } catch {
+ folders.set(id, { id, name: `Folder ${id}`, parentId: '' });
+ }
+ }
+ const state = current.getState();
+ json(res, 200, {
+ folders: Array.from(folders.values()),
+ serviceTypes,
+ selected: state.serviceType.id,
+ selectedName: state.serviceType.name,
+ apiBase: API_BASE,
+ method: pco.method,
+ });
+ } catch (err) {
+ log(`browse failed: ${err?.message || err}`);
+ json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) });
+ }
+ },
+
+ /** Which plan would be followed for a service type — shown before saving. */
+ 'GET /preview': async (req, res) => {
+ if (!current) return json(res, 503, { error: 'Module not running.' });
+ const params = new URLSearchParams(req.search || '');
+ const stId = String(params.get('serviceTypeId') || '').trim();
+ if (!stId) return json(res, 400, { error: 'serviceTypeId is required.' });
+ try {
+ const rec = await current.choosePlanId(stId);
+ if (!rec) return json(res, 200, { plan: null });
+ const a = rec.attributes || {};
+ json(res, 200, {
+ plan: {
+ id: String(rec.id),
+ title: String(a.title || ''),
+ seriesTitle: String(a.series_title || ''),
+ dates: String(a.dates || ''),
+ itemsCount: Number(a.items_count) || 0,
+ totalLength: Number(a.total_length) || 0,
+ sortDate: a.sort_date || '',
+ },
+ });
+ } catch (err) {
+ json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) });
+ }
+ },
+ };
+ },
+};
diff --git a/modules/pco-plan/setup.html b/modules/pco-plan/setup.html
new file mode 100644
index 0000000..9593838
--- /dev/null
+++ b/modules/pco-plan/setup.html
@@ -0,0 +1,372 @@
+
+
+
+
+
+PCO Plan — Planning Center setup
+
+
+
+
+
Browse your Planning Center Services folders and choose the service type whose plans the PCO Plan tiles should follow.
+
+
+
Currently following
+
Loading…
+
+
+
+
+
Service types
+
+
Loading folders from Planning Center…
+
+
+ Admin passcode:
+
+
+
+
+
Credentials (Application ID + Secret from api.planningcenteronline.com, or an OAuth token) are entered under PCO Plan in Admin. Saving here re-initialises the module; open tiles pick up the new plan on their own.