Files
Waters ChurchandClaude Fable 5.1 0c4828eb26 pco-plan: note categories as switches, top-bar variables as chips, no text-size field
Item notes get their own gear group, after Colors, with the existing
showNotes switch as the group's on/off in its title row. Its categories
come from Planning Center (GET /note-categories → the service type's
item_note_categories, by name, cached 10 min, an empty list rather than
an error when unconfigured or unreachable) and render as switches, three
across, all on until someone flips one; the tile reads an unset value as
every category, an array as exactly those, and a layout's old
comma-separated string as that list (blank = all).

The top bar template is a `template` field: one chip per placeholder
({series} {part} {serviceType} {date} {shortDate} {time} {length}
{items}) to drag or click into the text, with the help down to the
joining rules. Rendering is unchanged.

Text size leaves the gear window: the title bar's A− / A+ already size
the text and save it with the tile; the client falls back to 14 px when
nothing is saved.

tools/pco-mock.js serves item_note_categories (the categories its notes
use plus "Stage", which none does yet) so the route can be exercised.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:35:47 -04:00

1484 lines
60 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 /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 /note-categories → { options } for a tile's "Item notes" categories:
* the service type's item note categories, by name
* 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 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)
* 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
* GET /service_types/{st}/item_note_categories (note category names)
*
* 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.
*/
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(/\/+$/, '');
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;
/** How many distinct documents seen live are remembered for the admin selects. */
const SEEN_DOCUMENTS_MAX = 200;
/** How long a service type's item note categories are believed before Planning Center is asked again. */
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
/**
* 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;
/* ── 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(),
service: clearedService(),
timing: null, // the admin's service-timing choices (no secrets)
};
}
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, // 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,
};
}
/**
* 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 — 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);
if (env) return env;
const home = os.homedir();
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
const root = path.join(__dirname, '..', '..');
const dataDir = fromEnv
? path.resolve(root, fromEnv)
: process.platform === 'win32'
? path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash')
: process.platform === 'darwin'
? path.join(home, 'Library', 'Application Support', 'ProdDash')
: path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
for (const file of [path.join(dataDir, 'proddash.json'), path.join(root, 'config', 'proddash.json')]) {
try {
const port = Number(JSON.parse(fs.readFileSync(file, 'utf8')).port);
if (port) return port;
} catch { /* try the next one */ }
}
return 24500;
}
/** Port a browser used to reach us, from a Host header ("10.3.8.41:24500"). */
function portFromHost(hostHeader) {
const m = /:(\d+)$/.exec(String(hostHeader || '').trim());
if (m) return Number(m[1]) || 0;
return hostHeader ? 80 : 0;
}
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}`;
}
/** "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 ─────────────────────────────────────────── */
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 ───────────────────────────────────────────────────── */
/** 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;
}
}
/** 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);
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: '',
};
}
/** "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, 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 — */
function frameOf() {
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;
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 — */
function planPath(planId) {
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
}
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
async function listPlans(stId, { future = 5, past = 3 } = {}) {
const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
const [fut, pst] = await Promise.all([
pco.get(base, { filter: 'future', order: 'sort_date', per_page: future }),
pco.get(base, { filter: 'past', order: '-sort_date', per_page: past }),
]);
const byId = new Map();
for (const rec of [...(fut?.data || []), ...(pst?.data || [])]) {
const sortDate = Date.parse(rec?.attributes?.sort_date || '');
if (!rec?.id || !Number.isFinite(sortDate)) continue;
byId.set(String(rec.id), { rec, sortDate });
}
return Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
}
/** Today's plan, else the next upcoming one, else the most recent past one. */
function pickPlan(plans) {
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 choosePlanId(stId) {
return pickPlan(await listPlans(stId));
}
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) {
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 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());
}
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);
}
/* — item note categories (a tile's "Item notes" → Categories switches) — */
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
let noteCategoriesInFlight = null;
/**
* The service type's item note categories, by name — notes are matched
* to categories by name, so the names are the values. Believed for
* NOTE_CATEGORIES_TTL_MS, read once however many tiles ask at the same
* moment; a failed re-read keeps serving the last good list. A module
* that cannot ask (no credentials, no service type) answers an empty
* list — the gear window then says there is nothing to choose from yet.
*/
async function noteCategoryOptions() {
if (!pco.configured || !serviceTypeId) return [];
if (Date.now() - noteCategories.at < NOTE_CATEGORIES_TTL_MS) return noteCategories.options;
if (!noteCategoriesInFlight) {
noteCategoriesInFlight = (async () => {
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/item_note_categories`);
const seen = new Set();
const options = (body.data || [])
.filter((c) => !c?.attributes?.deleted_at)
.map((c) => ({ name: String(c.attributes?.name || '').trim(), sequence: Number(c.attributes?.sequence) || 0 }))
.sort((a, b) => a.sequence - b.sequence)
.filter((c) => c.name && !seen.has(c.name.toLowerCase()) && seen.add(c.name.toLowerCase()))
.map((c) => ({ value: c.name, label: c.name }));
noteCategories = { at: Date.now(), options };
return options;
})().finally(() => { noteCategoriesInFlight = null; });
}
try {
return await noteCategoriesInFlight;
} catch (err) {
log(`item note categories read failed: ${err?.message || err}`);
return noteCategories.options;
}
}
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();
broadcastTimers();
schedulePoll(delay);
})();
try {
await inFlight;
} finally {
inFlight = null;
}
}
/* — 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;
// 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)}` : `Started ${fmtTime(svc.startedAt)}`),
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;
// 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: stale ? 'muted' : over > planned * 0.25 + 30000 ? 'danger' : over > 0 ? 'warn' : 'ok',
};
currentItem.detail = stale ? `${itemDetail} · ProPresenter unreachable` : 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;
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();
/** 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 (!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 = {
...clearedLive(),
following: keep.following,
available: keep.available,
reason: keep.reason,
ppEnabled: keep.ppEnabled,
ppReachable: keep.ppReachable,
activeName: keep.activeName,
};
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. */
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;
}
/** 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 = '';
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();
broadcastTimers();
return;
}
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) {
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 };
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();
broadcastTimers();
}
if (stopped) return;
clearTimeout(nnRetryTimer);
nnRetryTimer = setTimeout(connectNowNext, retryMs);
nnRetryTimer.unref?.();
}
function connectNowNext() {
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' } },
(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) => {
if (req !== nnRequest) return; // superseded by a reconnect on the learned port
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);
nnPort = guessedPort;
log(`port ${port} refused the loopback (${err.message}) — back to port ${guessedPort}`);
}
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 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';
log(state.lastError);
} else {
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
schedulePoll(0);
}
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(() => {
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,
refresh() {
clearTimeout(pollTimer);
return poll();
},
listPlans,
pickPlan,
timersSnapshot,
documentOptions,
noteCategoryOptions,
};
return {
stop() {
stopped = true;
clearInterval(heartbeat);
clearInterval(ticker);
clearTimeout(pollTimer);
clearTimeout(nnRetryTimer);
try { nnRequest?.destroy(); } catch { /* gone */ }
for (const set of [streams, timerStreams]) {
for (const res of set) {
try { res.end(); } catch { /* already gone */ }
}
set.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 — pick one in the Service type list below' };
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 service = win ? ` · ${serviceLabel(win)} ${svc.phase}` : '';
const live = wantFeed ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
return { status: 'ok', message: `Following ${name}${plan}${service}${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));
};
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) => {
current?.learnPort(req.headers.host);
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
},
'GET /stream': (req, res) => {
current?.learnPort(req.headers.host);
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();
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) })
);
},
/* — 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 a tile's "Item notes" → Categories switches: the
* configured service type's item note categories, by name. Always a
* 200 with a list — empty while the module is unconfigured, not
* running or cannot reach Planning Center — because the gear window
* has nothing better to do with an error than show an empty list.
*/
'GET /note-categories': async (req, res) => {
let options = [];
try {
if (current) options = await current.noteCategoryOptions();
} catch (err) {
log(`note categories: ${err?.message || err}`);
}
json(res, 200, { options });
},
/**
* Options for the admin page's "Service type" select: every service
* type, grouped by its Planning Center folder path — so the folders
* are browsable as <optgroup>s right in the admin form.
*/
'GET /service-types': async (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const { pco } = current;
if (!pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
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: '' });
}
}
// "Campuses › North Campus" — the folder path from the root down.
const pathOf = (parentId) => {
const names = [];
let id = parentId;
const seen = new Set();
while (id && folders.has(id) && !seen.has(id)) {
seen.add(id);
names.unshift(folders.get(id).name);
id = folders.get(id).parentId;
}
return names.join(' › ');
};
const options = serviceTypes
.map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) }))
.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
json(res, 200, { options });
} catch (err) {
log(`service type list failed: ${err?.message || err}`);
json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) });
}
},
/**
* Options for the admin page's "Plan" select, for the service type the
* admin currently has picked (?serviceTypeId=…): the upcoming and recent
* plans, with the one Automatic would choose marked.
*/
'GET /plans': async (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const { pco } = current;
const params = new URLSearchParams(req.search || '');
const stId = String(params.get('serviceTypeId') || '').trim();
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
if (!pco.configured || !stId) return json(res, 200, { options: [auto] });
try {
const plans = await current.listPlans(stId, { future: 8, past: 6 });
const picked = current.pickPlan(plans);
const now = Date.now();
const options = [auto];
// Upcoming first (soonest at the top), then the recent ones.
const ordered = [
...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
];
for (const { rec, sortDate } of ordered) {
const a = rec.attributes || {};
const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())];
if (a.series_title) bits.push(String(a.series_title));
if (a.title) bits.push(String(a.title));
let label = bits.join(' — ');
if (picked && rec.id === picked.id) label += ' (Automatic picks this)';
options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' });
}
json(res, 200, { options });
} catch (err) {
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) });
}
},
};
},
};