Files
ProdDash/modules/pco-plan/server.js
T
Waters ChurchandClaude Opus 5 aec9c47ed4 PCO Plan 1.8.0: the plan follows AbleSet, and auto-scroll stays in its tile
Three things, the first on its own.

**Auto-scroll moves the tile, not the board.** `scrollIntoView` walks up and
scrolls every ancestor that can scroll, so bringing the current item into
view dragged the whole dashboard with it whenever the board was taller than
the window. The list's own `scrollTop` is moved instead, measured from
rectangles because the rows' offset parent is the tile rather than the list.

**The current item fills as its song plays.** When an AbleSet module is
installed, configured and playing a song that IS the item on screen, that
item's background fills left to right with the song. The song is tied to a
plan item by the same name matching the live ProPresenter document already
uses, so a bar can never quietly fill the sermon while the band rehearses
something else.

**Its sections slide past, bottom right of that item.** Every section of the
song as a strip, the played ones dimmed, the one playing filling in turn and
kept centred — by moving that strip's own `scrollLeft`, for the same reason
as above. The chips are rebuilt only when the song changes; moving to the
next section just moves the classes, so the strip keeps its place.

Both are switches in the tile's gear, **Song progress** and **Song
sections**, and both draw nothing at all unless there is an AbleSet module
to follow: the server reports `available: false` for no module, a disabled
one, or one with no host, and re-checks every 15 s so installing AbleSet
later lights it up without a restart.

The server follows the AbleSet module over loopback exactly as it follows
ProPresenter's, and republishes a small slice — song, sections, playhead in
beats with a stamp — as its own `ableset` SSE event. Its own event because
it moves five times a second and must never drag the plan (items, teams)
onto the wire with it; the tile carries the playhead forward from the tempo
between packets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 09:14:19 -04:00

2038 lines
83 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)
* plus `ableset` events: the song AbleSet is playing, its
* sections and playhead, when there is an AbleSet module
* to follow (see below). Its own event so the playhead
* never drags the plan onto the wire with it.
* 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 /teams → { options } for a Teams tile's "Teams" switches
* GET /team-avatar/<planPersonId> → that person's photo, proxied + cached
* 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)
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
*
* Following AbleSet: the same loopback trick, entirely optional. When this
* ProdDash has an AbleSet module that is configured and answering, its song,
* sections and playhead are passed to the tiles, and the song is matched to a
* plan item by name so a tile can fill that item as the song plays. With no
* such module — not installed, disabled, no host — tiles are told
* `available: false` and draw none of it.
*
* 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, similarity, MATCH_THRESHOLD } = 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;
/** The AbleSet module, followed the same way — entirely optional (see below). */
const ABLESET_ID = 'ableset';
const ABLESET_RETRY_MS = 3000;
const ABLESET_MISSING_RETRY_MS = 15000;
/** Fastest the playhead is passed on; AbleSet offers more than a tile can draw. */
const ABLESET_POS_MS = 200;
const NOW_NEXT_MISSING_RETRY_MS = 15000;
/** How many distinct documents seen live are remembered for the admin selects. */
const SEEN_DOCUMENTS_MAX = 200;
/**
* Team assignments change far more slowly than the plan itself (someone
* confirms, a position is filled), so they ride a slow lane: re-read when the
* plan changes, and otherwise no more often than this.
*/
const TEAMS_MIN_MS = 60 * 1000;
/** How long the service type's own team list is believed. */
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
const AVATAR_CACHE_MAX = 300;
const AVATAR_TTL_MS = 6 * 60 * 60 * 1000;
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
/** 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: [],
teams: [], // who is scheduled, by team (the Teams tile)
teamsError: '', // why the last team read failed, if it did
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 }
};
}
/**
* What tiles are told about AbleSet. `available` false covers every way there
* may be nothing to draw — no module installed, disabled, or not answering —
* and the tiles then show none of it at all.
*/
function clearedAbleSet() {
return {
available: false,
configured: false,
reachable: false,
reason: '',
playing: false,
tempo: null,
song: null,
section: null,
sections: [],
beats: null,
itemId: '',
at: 0,
};
}
/** 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: '',
};
}
/* ── team assignments (the Teams tile) ──────────────────────────────── */
/**
* Planning Center's scheduling status letters. The API answers "C", "U" or
* "D"; some accounts and older records spell the word out, so match on the
* first letter and treat anything unknown as not yet confirmed — the state a
* booth should chase.
*/
function teamStatusOf(raw) {
const s = String(raw || '').trim().toUpperCase();
if (s.startsWith('C')) return 'confirmed';
if (s.startsWith('D')) return 'declined';
return 'unconfirmed';
}
/**
* The person's photo. A PlanPerson usually carries `photo_thumbnail`; when a
* plan is read with `include=person` the Person record carries the fuller set
* of names Planning Center has used for the same thing over the years. Take
* the first that looks like a URL — a missing photo is normal and the tile
* draws initials instead.
*/
function teamPhotoOf(attrs, person) {
const candidates = [
attrs?.photo_thumbnail,
attrs?.photo_url,
person?.attributes?.avatar,
person?.attributes?.photo_thumbnail_url,
person?.attributes?.photo_url,
person?.attributes?.thumbnail_url,
];
for (const c of candidates) {
const url = String(c || '').trim();
if (/^https?:\/\//i.test(url)) return url;
}
return '';
}
/**
* `/plans/{id}/team_members?include=team,person` → the teams a tile draws,
* each with its people. Teams keep Planning Center's own order (a Team's
* `sequence`, then name); people sort by position, then by name, so a tile
* reads the way the schedule page does.
*
* Everyone scheduled belongs to a team, but a record whose team relationship
* is missing (a deleted team, a partial response) is not dropped on the
* floor — it lands under "Unassigned" where somebody will notice it.
*
* `allTeams` is the service type's own team list. Planning Center only
* returns a team here once somebody is scheduled on it, so without that list
* a team nobody has signed up for is invisible — which is the one a booth
* most wants to see. It also carries the authoritative name and order.
*/
function shapeTeams(body, allTeams) {
const included = new Map();
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
const teams = new Map();
for (const pp of body.data || []) {
const a = pp.attributes || {};
const teamRef = pp.relationships?.team?.data;
const teamRec = teamRef ? included.get(`Team/${teamRef.id}`) : null;
const teamId = teamRef?.id ? String(teamRef.id) : 'unassigned';
if (!teams.has(teamId)) {
teams.set(teamId, {
id: teamId,
name: String(teamRec?.attributes?.name || '').trim() || (teamRef?.id ? `Team ${teamId}` : 'Unassigned'),
sequence: Number(teamRec?.attributes?.sequence) || 0,
members: [],
});
}
const personRef = pp.relationships?.person?.data;
const personRec = personRef ? included.get(`Person/${personRef.id}`) : null;
teams.get(teamId).members.push({
id: String(pp.id),
personId: personRef?.id ? String(personRef.id) : '',
name: String(a.name || personRec?.attributes?.name || '').trim() || 'Unnamed',
position: String(a.team_position_name || '').trim(),
status: teamStatusOf(a.status),
declineReason: String(a.decline_reason || '').trim(),
notes: String(a.notes || '').trim(),
// The URL itself stays on the server; the tile asks for /team-avatar/<id>.
hasPhoto: Boolean(teamPhotoOf(a, personRec)),
});
}
// Teams nobody is scheduled on yet, and the real name/order for the rest.
for (const t of allTeams || []) {
const known = teams.get(t.id);
if (known) {
if (t.name) known.name = t.name;
known.sequence = t.sequence;
} else {
teams.set(t.id, { id: t.id, name: t.name, sequence: t.sequence, members: [] });
}
}
const out = [...teams.values()];
out.sort((x, y) => x.sequence - y.sequence || x.name.localeCompare(y.name));
for (const team of out) {
team.members.sort((x, y) => x.position.localeCompare(y.position) || x.name.localeCompare(y.name));
team.counts = {
total: team.members.length,
confirmed: team.members.filter((m) => m.status === 'confirmed').length,
unconfirmed: team.members.filter((m) => m.status === 'unconfirmed').length,
declined: team.members.filter((m) => m.status === 'declined').length,
};
}
return out;
}
/* Avatars: fetched once, kept a while, and served from ProdDash so no browser
ever talks to Planning Center (same reasoning as every other upstream). */
const avatarCache = new Map(); // url → { buf, type, at } — survives a re-init
const avatarInFlight = new Map();
function fetchAvatar(url) {
const hit = avatarCache.get(url);
if (hit && Date.now() - hit.at < AVATAR_TTL_MS) return Promise.resolve(hit);
if (avatarInFlight.has(url)) return avatarInFlight.get(url);
const p = (async () => {
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: 'follow' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const type = String(response.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
if (!type.startsWith('image/')) throw new Error(`not an image (${type})`);
const buf = Buffer.from(await response.arrayBuffer());
if (buf.length > AVATAR_MAX_BYTES) throw new Error('too large');
if (avatarCache.size >= AVATAR_CACHE_MAX) avatarCache.delete(avatarCache.keys().next().value);
const entry = { buf, type, at: Date.now() };
avatarCache.set(url, entry);
return entry;
})().finally(() => avatarInFlight.delete(url));
avatarInFlight.set(url, p);
return p;
}
/** https anywhere, or plain http on this machine — which is the mock, nothing else. */
function avatarUrlAllowed(url) {
try {
const u = new URL(String(url || ''));
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname));
} catch {
return false;
}
}
/** Answer with the photo at `url`; a failure is a 404/502 and the tile draws initials. */
async function serveAvatar(res, url) {
if (!avatarUrlAllowed(url)) {
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
return void res.end('No photo');
}
try {
const img = await fetchAvatar(url);
res.writeHead(200, { 'Content-Type': img.type, 'Content-Length': img.buf.length, 'Cache-Control': 'private, max-age=3600' });
res.end(img.buf);
} catch {
res.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
res.end('Photo unavailable');
}
}
/** "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;
}
}
/* — team assignments (the Teams tile) — */
/**
* The service type's teams, cached: they change when somebody edits the
* service type, not during a service. A failed read is not fatal — the
* plan's own teams still draw, just without the empty ones.
*/
let serviceTeams = { at: 0, list: [] };
async function readServiceTeams() {
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
try {
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/teams`);
const list = (body.data || []).map((t, i) => ({
id: String(t.id),
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
sequence: Number(t.attributes?.sequence) || i + 1,
}));
serviceTeams = { at: Date.now(), list };
} catch (err) {
log(`service type team list read failed: ${err?.message || err}`);
serviceTeams.at = Date.now(); // don't retry every poll
}
return serviceTeams.list;
}
/** planPersonId → photo URL, so a browser never learns the CDN address. */
let teamPhotos = new Map();
let teamsReadAt = 0;
/**
* Who is scheduled on this plan. Asked for with `include=team,person` and
* read again defensively without the includes when an account refuses
* them, exactly as the items read does: a tile that can name people
* without photos is far better than a tile that shows nothing.
*/
async function readTeams(planId) {
const target = `${planPath(planId)}/team_members`;
let body;
try {
body = await pco.getAll(target, { include: 'team,person' });
} catch (err) {
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
log(`team include rejected (${err.message}) — reading team members without includes`);
body = await pco.getAll(target);
} else {
throw err;
}
}
const teams = shapeTeams(body, await readServiceTeams());
const photos = new Map();
const included = new Map();
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
for (const pp of body.data || []) {
const personRef = pp.relationships?.person?.data;
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
if (url) photos.set(String(pp.id), url);
}
teamPhotos = photos;
teamsReadAt = Date.now();
return teams;
}
/**
* Refresh the team list when it is worth it: a new plan, or the slow lane
* coming round. A failure keeps the last good list and says so in the
* state — the plan itself is what the booth needs, and losing the roster
* must never cost them the running order.
*/
async function refreshTeams(planId, force) {
if (!force && Date.now() - teamsReadAt < TEAMS_MIN_MS) return;
try {
const teams = await readTeams(planId);
const before = state.teams.reduce((n, t) => n + t.members.length, 0);
const after = teams.reduce((n, t) => n + t.members.length, 0);
state.teams = teams;
state.teamsError = '';
if (force || before !== after) {
log(`teams: ${after} scheduled across ${teams.length} team${teams.length === 1 ? '' : 's'}`);
}
} catch (err) {
state.teamsError = err?.message || String(err);
log(`team read failed: ${state.teamsError}`);
}
}
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
function teamOptions() {
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
}
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.teams = [];
state.teamsError = "";
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);
// The roster rides its own slow lane and never fails the plan read.
await refreshTeams(plan.id, planChanged);
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. */
/* — AbleSet, over loopback like the ProPresenter feed above — */
/**
* The AbleSet module, if this ProdDash has one. Nothing here is required:
* a dashboard with no AbleSet module, a disabled one, or one with no host
* configured simply reports `available: false` and the tiles draw none of
* it. Installing AbleSet later lights it up without a restart, because a
* 404 is retried on the slow timer like any other missing feed.
*
* What tiles get is deliberately small — the song, its sections, and the
* playhead in beats with a stamp — because it moves five times a second
* and must never drag the plan (items, teams) onto the wire with it. It
* rides its own `ableset` event on the module's one stream.
*/
let absRequest = null;
let absRetryTimer = null;
let absPosTimer = null;
let absLastSent = 0;
let absLastStill = '';
let abs = clearedAbleSet();
function absPart(p) {
if (!p || typeof p !== 'object') return null;
const name = String(p.name || '').trim();
if (!name) return null;
const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
return { name, index: n(p.index), start: n(p.start), end: n(p.end) };
}
/**
* Which plan item AbleSet's song is, by the same name matching the live
* ProPresenter document uses. Without this a progress bar would happily
* fill the sermon while the band rehearses the next song: the tile only
* draws progress when the song AbleSet is playing IS the item on screen.
*/
function absItemFor(songName) {
if (!songName) return '';
let best = null;
for (const item of state.items) {
if (item.itemType === 'header') continue;
const score = Math.max(
similarity(songName, item.title),
item.song ? similarity(songName, item.song.title) : 0
);
if (score >= MATCH_THRESHOLD && (!best || score > best.score)) best = { id: item.id, score };
}
return best ? best.id : '';
}
function applyAbleSet(view) {
const song = absPart(view?.song);
abs = {
available: true,
configured: Boolean(view?.configured),
reachable: Boolean(view?.reachable),
playing: Boolean(view?.playing),
tempo: Number.isFinite(Number(view?.tempo)) ? Number(view.tempo) : null,
song,
section: absPart(view?.section),
sections: Array.isArray(view?.sections)
? view.sections.slice(0, 60).map((s) => ({ name: String(s?.name || ''), color: s?.color || null }))
: [],
beats: Number.isFinite(Number(view?.pos?.beats)) ? Number(view.pos.beats) : null,
itemId: song ? absItemFor(song.name) : '',
at: Date.now(),
};
broadcastAbleSet();
}
/** Only the playhead moved: keep everything else and restamp. */
function applyAbleSetPos(pos) {
if (!abs.available) return;
abs = {
...abs,
beats: Number.isFinite(Number(pos?.beats)) ? Number(pos.beats) : abs.beats,
at: Date.now(),
};
broadcastAbleSet();
}
/**
* Fan the slice out. A change in anything but the playhead goes at once;
* the playhead itself is throttled, since AbleSet offers more packets
* than any tile can draw.
*/
function broadcastAbleSet(force) {
const { beats, at, ...still } = abs;
const frame = JSON.stringify(still);
const changed = frame !== absLastStill;
absLastStill = frame;
const now = Date.now();
if (!force && !changed && now - absLastSent < ABLESET_POS_MS) {
// Due soon anyway — make sure the last packet of a burst still lands.
if (!absPosTimer) {
absPosTimer = setTimeout(() => { absPosTimer = null; broadcastAbleSet(true); }, ABLESET_POS_MS);
absPosTimer.unref?.();
}
return;
}
clearTimeout(absPosTimer);
absPosTimer = null;
absLastSent = now;
if (!streams.size) return;
writeAll(streams, `event: ableset\ndata: ${JSON.stringify(abs)}\n\n`);
}
function absUnavailable(reason, retryMs) {
if (abs.available || abs.reason !== reason) {
abs = { ...clearedAbleSet(), reason };
broadcastAbleSet(true);
}
if (stopped) return;
clearTimeout(absRetryTimer);
absRetryTimer = setTimeout(connectAbleSet, retryMs);
absRetryTimer.unref?.();
}
function connectAbleSet() {
if (stopped) return;
const port = nnPort;
const req = http.get(
{ host: '127.0.0.1', port, path: `/api/modules/${ABLESET_ID}/stream`, headers: { Accept: 'text/event-stream' } },
(res) => {
if (res.statusCode !== 200) {
res.resume();
return absUnavailable(
res.statusCode === 404 ? 'no AbleSet module here' : `AbleSet module feed answered ${res.statusCode}`,
ABLESET_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 (!data) continue;
try {
if (event === 'state') applyAbleSet(JSON.parse(data).state ?? JSON.parse(data));
else if (event === 'pos') applyAbleSetPos(JSON.parse(data));
} catch { /* a bad frame is not fatal */ }
}
});
res.on('end', () => absUnavailable('AbleSet module feed ended', ABLESET_RETRY_MS));
res.on('error', () => absUnavailable('AbleSet module feed dropped', ABLESET_RETRY_MS));
}
);
req.on('error', (err) => {
if (req !== absRequest) return; // superseded by a reconnect on the learned port
absUnavailable(`ProdDash loopback failed: ${err.message}`, ABLESET_RETRY_MS);
});
req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
absRequest = req;
}
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();
clearTimeout(absRetryTimer);
try { absRequest?.destroy(); } catch { /* gone */ }
absRequest = null;
connectAbleSet();
}
/**
* 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();
}
// Always: whether there is an AbleSet module to follow is its answer, not
// an admin setting here.
connectAbleSet();
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,
getAbleSet: () => abs,
learnPort,
refresh() {
clearTimeout(pollTimer);
return poll();
},
listPlans,
pickPlan,
timersSnapshot,
documentOptions,
noteCategoryOptions,
teamOptions,
avatarUrlFor: (id) => teamPhotos.get(String(id)) || "",
};
return {
stop() {
stopped = true;
clearInterval(heartbeat);
clearInterval(ticker);
clearTimeout(pollTimer);
clearTimeout(nnRetryTimer);
clearTimeout(absRetryTimer);
clearTimeout(absPosTimer);
try { nnRequest?.destroy(); } catch { /* gone */ }
try { absRequest?.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(),
ableset: current ? current.getAbleSet() : clearedAbleSet(),
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`);
res.write(`event: ableset\ndata: ${JSON.stringify(current ? current.getAbleSet() : clearedAbleSet())}\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 a Teams tile's "Teams" switches. Like the note
* categories, always a 200 with a list: an empty one simply means there
* is nothing scheduled to choose from yet, which the gear window says
* plainly.
*/
'GET /teams': (req, res) => {
json(res, 200, current ? current.teamOptions() : { options: [] });
},
/**
* A scheduled person's photo, proxied from Planning Center and cached.
* The tile asks by plan-person id and never sees the CDN URL; an id the
* module does not know (an old tile, a person since unscheduled) is a
* 404 and the tile falls back to initials.
*/
'GET /team-avatar/*': (req, res) => {
const id = String(req.wildcard || '').replace(/^\/+/, '');
const url = current ? current.avatarUrlFor(id) : '';
serveAvatar(res, url);
},
/** 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) });
}
},
};
},
};