Files
ProdDash/modules/pco-plan/server.js
T

1519 lines
62 KiB
JavaScript
Raw Normal View History

'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
* places the live document on a plan item (matching.js placeDocument): by
* name, or — for the sermon, whose document is named after the sermon and
* never after its plan item — by reading like the plan itself ("YYMMDD
* Series Title - Part #"), which lands it on the plan's message 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 { placeDocument, messageItemOf, playlistMirrorsPlan } = 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 = '';
let lastMessageItemId = null;
let lastMatchError = '';
/* — 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;
noteMessageItem(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);
}
/** Say, once per plan, which item a document named like the plan will land on. */
function noteMessageItem(items) {
const msg = messageItemOf(items);
const id = msg ? msg.id : '';
if (id === lastMessageItemId) return;
lastMessageItemId = id;
log(msg
? `message item: "${msg.title}" — a live document named like this plan (its date, series and part) lands here`
: 'no message item in this plan — a document named like the plan has nowhere to land');
}
/* — 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, hintFrom(lastView));
state.live.matched = Boolean(hit);
if (hit) {
state.live.currentScore = hit.score;
startItem(hit.item.id, keep.activeName, hit);
}
}
}
/**
* The plan item a live document stands for — { item, score, via } or
* null. The rules live in matching.js (placeDocument): by name, then the
* message rule for a document named like the plan itself; the playlist
* position only breaks ties, and only while the playlist mirrors the plan.
*/
function matchItem(name, hint) {
return placeDocument(name, { items: state.items, plan: state.plan, currentItemId: state.live.currentItemId, hint });
}
/** The live item's place in ProPresenter's playlist, and whether that playlist mirrors the plan. */
function hintFrom(view) {
const index = Number.isInteger(view?.activeIndex) ? view.activeIndex : -1;
return { index, mirror: index >= 0 && playlistMirrorsPlan(view?.items, state.items) };
}
/** 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;
if (!live.available) log('following the ProPresenter module feed');
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;
}
rememberDocument(name);
if (follow) {
try {
const hit = matchItem(name, hintFrom(view));
live.matched = Boolean(hit);
if (hit) {
live.currentScore = hit.score;
if (hit.item.id !== live.currentItemId) startItem(hit.item.id, name, hit);
} else {
// Said in the log, because on the wall it looks like the plan
// stopped following: the highlight stays where it was.
const staying = state.items.find((it) => it.id === live.currentItemId);
log(`live document "${name}" is not in the plan — ${staying ? `"${staying.title}" stays current` : 'no current item'}`);
}
lastMatchError = '';
} catch (err) {
// Never silent: a matcher that throws would otherwise leave the
// highlight stuck with nothing in the log to say why.
const msg = err?.message || String(err);
if (msg !== lastMatchError) {
lastMatchError = msg;
log(`matching "${name}" failed: ${msg}`);
}
}
}
lastLiveName = name;
// The same document is also a possible service start / end cue.
syncServiceSafe(Date.now(), name);
broadcast();
broadcastTimers();
}
/** Make an item current; `liveName` and `hit` say what placed it, for the log. */
function startItem(itemId, liveName, hit) {
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);
const how = hit
? ` (${hit.via}${hit.via === 'name' ? ` ${Number(hit.score).toFixed(2)}` : ''}${liveName ? `: "${liveName}"` : ''})`
: '';
log(`live item → ${item?.title || itemId}${how}`);
}
function nowNextUnavailable(reason, retryMs) {
const live = state.live;
if (live.available || live.reason !== reason) {
// Every drop is in the log: a highlight that stopped moving is
// otherwise indistinguishable from a feed that went away.
log(`ProPresenter module feed unavailable — ${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) });
}
},
};
},
};