PCO Plan 1.10.0: follow several service types in priority order, and read any of them from any dashboard

An account runs more than one service type — a Sunday morning, a Wednesday
night, a youth service, a second campus — and until now that meant one
ProdDash per type. The module now follows as many as are configured.

Admin picks them in priority order. Shell 1.12.0 gives `multiselect` an
`orderable: true` that stops being a ticklist: the picks sit in a numbered
list with a nudge either way and a remove, over a dropdown holding what is
left. Forty switches to read past is not a picker, and a ticklist cannot
carry an order anyway. The order is the setting — when two types have a plan
at the same date and time, the one higher up wins.

The server gathers candidate plans from every ticked type and tiers them:
today's plan first, then the next upcoming, then the most recent past, with
the configured order breaking a tie. A type that fails is skipped rather
than failing the read, unless it is an auth or rate-limit answer, which
belongs to the whole connection. The types not being followed are read on a
slower lane (45 s) into `state.others`, so they are there to look at without
a second connection.

Every tile's title bar carries a service-type control. Picking one moves
every PCO Plan tile on that browser's dashboard — plan, teams and song key
together — onto its plan, over a small page bus in `viewed-type.js`; the
choice is per browser and survives a reload. The control hides itself when
only one type is configured.

Live machinery stays with the type being followed: another type's plan shows
its running order with no now marker, no AbleSet and no service clock — it
shows that plan's own scheduled time instead, since borrowing the running
clock of a different service is exactly the misreading the amber top-bar
stripe exists to prevent.

Old configs carry `serviceTypeId`; it is read once into the new array and
saved, and plan pins still resolve bare, so nothing has to be re-entered.

`tools/pco-mock.js` takes `--types N` to pad the list out, for trying the
picker against an account with dozens of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-06 14:32:19 -04:00
co-authored by Claude Opus 5
parent f9cd292693
commit 7bb1131378
13 changed files with 800 additions and 131 deletions
+38 -20
View File
@@ -14,6 +14,7 @@
import createTeams from "./teams-client.js";
import createKey from "./key-client.js";
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
import { prettyKey } from "./key-notation.js";
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
@@ -38,6 +39,10 @@ function createPlan({ root, moduleApi }) {
let lastCurrentId = null;
let lastListKey = '';
let absView = null; // what the server says AbleSet is doing, if anything
// The plan this dashboard is looking at — usually the one the server
// follows, otherwise one of the others it also reads.
let view = viewOf(null);
let offViewed = null;
let userScrolledAt = 0;
root.innerHTML = `
@@ -89,6 +94,7 @@ function createPlan({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
applyPrefs();
}
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
@@ -144,7 +150,7 @@ function createPlan({ root, moduleApi }) {
/* ── which items to show ────────────────────────────────────────── */
function visibleItems() {
const items = Array.isArray(state?.items) ? state.items : [];
const items = Array.isArray(view.items) ? view.items : [];
return items.filter((it) => {
if (it.itemType === 'header' && !on('showHeaders')) return false;
if (it.servicePosition === 'pre' && !on('showPreService')) return false;
@@ -194,14 +200,14 @@ function createPlan({ root, moduleApi }) {
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
function templateValues() {
const plan = state?.plan || {};
const plan = view.plan || {};
const items = state?.items || [];
const planned = plannedLength();
return {
series: on('showSeries') ? plan.seriesTitle || '' : '',
part: on('showPart') ? plan.title || '' : '',
title: on('showPart') ? plan.title || '' : '',
servicetype: on('showServiceType') ? state?.serviceType?.name || '' : '',
servicetype: on('showServiceType') ? view.name || '' : '',
date: on('showDate') ? plan.dates || '' : '',
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
time: on('showServiceTime') && plan.serviceStartsAt
@@ -239,12 +245,12 @@ function createPlan({ root, moduleApi }) {
}
function renderHead() {
const plan = state?.plan;
const plan = view.plan;
const show = on('showTopBar') && plan;
headEl.hidden = !show;
if (!show) return;
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan';
planTitleEl.textContent = line1 || line2 || view.name || 'Plan';
planSubEl.textContent = line1 ? line2 || '' : '';
planSubEl.hidden = !planSubEl.textContent;
renderClock();
@@ -258,9 +264,12 @@ function createPlan({ root, moduleApi }) {
* time once it has ended.
*/
function renderClock() {
const plan = state?.plan;
const live = state?.live || {};
const svc = state?.service || {};
const plan = view.plan;
const live = view.live ? (state?.live || {}) : {};
// The service window belongs to the type being followed. Another type's
// plan must not borrow its clock — it would read as that service running.
// With none, the fall-through below shows that plan's own start time.
const svc = view.live ? (state?.service || {}) : {};
clockLabelEl.textContent = '';
clockValueEl.textContent = '';
clockValueEl.className = 'pp-clock-value';
@@ -272,7 +281,7 @@ function createPlan({ root, moduleApi }) {
clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
if (live.following) {
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
const done = view.items.filter((it) => live.history?.[it.id]?.endedAt);
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
const drift = actual - planned;
@@ -321,13 +330,13 @@ function createPlan({ root, moduleApi }) {
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.reachable && !state.plan) {
if (!state.reachable && !view.plan) {
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
}
if (!state.plan) {
if (!view.plan) {
return {
title: 'No plan found',
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
hint: `${view.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
};
}
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' };
@@ -483,7 +492,7 @@ function createPlan({ root, moduleApi }) {
// Rebuild rows only when the plan content or the display settings
// changed; live highlighting and timers are applied in place.
const key = JSON.stringify([state.plan.id, state.items, cfg(), state.live?.following]);
const key = JSON.stringify([view.id, view.plan.id, view.items, cfg(), view.live && state.live?.following]);
if (key !== lastListKey) {
lastListKey = key;
listEl.innerHTML = '';
@@ -523,6 +532,7 @@ function createPlan({ root, moduleApi }) {
/** The row AbleSet's song belongs to — only ever the current one. */
function absRow() {
const a = absView;
if (!view.live) return null; // another service type is on screen
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
if (a.itemId !== lastCurrentId) return null;
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
@@ -627,15 +637,17 @@ function createPlan({ root, moduleApi }) {
/** Current / done classes and the elapsed timers, without rebuilding rows. */
function renderLiveMarks() {
const live = state?.live || {};
// Only the followed service type has a live item: this dashboard may be
// looking at another one, which has no ProPresenter and no clock.
const live = view.live ? (state?.live || {}) : {};
const now = serverNow();
const currentId = live.following ? live.currentItemId || '' : '';
const history = live.history || {};
const byId = new Map(state.items.map((it) => [it.id, it]));
const byId = new Map(view.items.map((it) => [it.id, it]));
// Everything before the current item counts as done, matched or not.
const doneIds = new Set();
if (currentId) {
for (const it of state.items) {
for (const it of view.items) {
if (it.id === currentId) break;
if (it.itemType !== 'header') doneIds.add(it.id);
}
@@ -676,8 +688,8 @@ function createPlan({ root, moduleApi }) {
/* ── footer: what ProPresenter is doing ─────────────────────────── */
function renderFoot() {
const live = state?.live;
const show = on('showLiveFooter') && live?.following && state?.plan;
const live = view.live ? state?.live : null;
const show = on('showLiveFooter') && live?.following && view.plan;
footEl.hidden = !show;
footEl.className = 'pp-foot';
if (!show) return;
@@ -713,7 +725,10 @@ function createPlan({ root, moduleApi }) {
/* ── render + ticking ───────────────────────────────────────────── */
function render() {
view = viewOf(state);
switcher.sync();
wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
wrap.classList.toggle('header-gradient', cfg().headerGradient !== false);
const color = (value, fallback) => {
@@ -744,9 +759,9 @@ function createPlan({ root, moduleApi }) {
function tick() {
renderAbleSet();
if (!state?.plan) return;
if (!view.plan) return;
renderClock();
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
if (view.live && state.live?.following && on('showItemRuntime')) renderLiveMarks();
}
/* ── live feed ──────────────────────────────────────────────────── */
@@ -805,11 +820,14 @@ function createPlan({ root, moduleApi }) {
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
offViewed = onViewedType(() => { lastListKey = ''; render(); });
listEl.addEventListener('wheel', onUserScroll, { passive: true });
listEl.addEventListener('touchmove', onUserScroll, { passive: true });
ticker = setInterval(tick, 1000);
},
stop() {
offViewed?.();
offViewed = null;
clearInterval(ticker);
ticker = null;
stream?.close();
+19 -3
View File
@@ -5,11 +5,14 @@
which item is current comes from the ProPresenter module, as ever. */
import { parseKey, prettyKey } from "./key-notation.js";
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
export default function createKey({ root, moduleApi }) {
let state = null;
let stream = null;
let feedOffline = false;
let view = viewOf(null);
let offViewed = null;
root.innerHTML = `
<div class="pk-wrap">
@@ -23,17 +26,21 @@ export default function createKey({ root, moduleApi }) {
const nameEl = root.querySelector('.pk-name');
const nextEl = root.querySelector('.pk-next');
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
const cfg = () => moduleApi.instanceSettings || {};
const on = (key) => cfg()[key] !== false; // unset = shown
/* ── the item on screen, and the one after it ───────────────────── */
function items() {
return Array.isArray(state?.items) ? state.items : [];
return Array.isArray(view.items) ? view.items : [];
}
function currentItem() {
const id = state?.live?.following ? state.live.currentItemId : '';
// Only the followed service type has a live item; on another one there
// is nothing current, so the tile says so rather than guessing.
const id = view.live && state?.live?.following ? state.live.currentItemId : '';
return id ? items().find((it) => it.id === id) || null : null;
}
@@ -63,6 +70,7 @@ export default function createKey({ root, moduleApi }) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
const item = currentItem();
if (!view.live) return moduleApi.setStatus('ok', `${view.name || 'Another service type'} — not the one live`);
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
// The key on screen stays — the band still needs it — but a tile must
// never look confidently live while the feed that chose it is gone.
@@ -101,7 +109,10 @@ export default function createKey({ root, moduleApi }) {
}
function render() {
view = viewOf(state);
switcher.sync();
wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
const live = state?.live;
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
const color = String(cfg().keyColor || '').trim();
@@ -120,7 +131,9 @@ export default function createKey({ root, moduleApi }) {
}
if (!item) {
drawKey('—');
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
nameEl.textContent = !view.live
? (view.name || 'Another service type')
: (state.live?.following ? 'Nothing live' : 'Not following ProPresenter');
nextEl.textContent = '';
return;
}
@@ -167,9 +180,12 @@ export default function createKey({ root, moduleApi }) {
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
offViewed = onViewedType(render);
render();
},
stop() {
offViewed?.();
offViewed = null;
stream?.close();
stream = null;
root.innerHTML = '';
+11 -8
View File
@@ -1,8 +1,8 @@
{
"id": "pco-plan",
"name": "PCO Plan",
"version": "1.9.0",
"proddash": ">=1.4.0",
"version": "1.10.0",
"proddash": ">=1.12.0",
"description": "Order of service, team assignments and the song key from Planning Center Services",
"provides": [
"timers"
@@ -46,11 +46,13 @@
"default": "",
"group": "Planning Center credentials"
},
"serviceTypeId": {
"type": "select",
"label": "Service type",
"default": "",
"optionsRoute": "/service-types"
"serviceTypes": {
"type": "multiselect",
"label": "Service types",
"orderable": true,
"optionsRoute": "/service-types",
"help": "Add every service type whose plans this ProdDash should follow. Order is priority: when two of them have a plan at the same date and time, the one higher up wins.",
"addLabel": "Add a service type…"
},
"planId": {
"type": "select",
@@ -63,7 +65,8 @@
}
],
"optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId"
"optionsDependsOn": "serviceTypes",
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
},
"pollSeconds": {
"type": "number",
+249 -68
View File
@@ -73,6 +73,7 @@ const path = require('path');
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
const {
MINUTE,
DEFAULT_SERVICE_LENGTH_MS,
serviceTimes,
serviceWindows,
selectService,
@@ -111,6 +112,12 @@ const SEEN_DOCUMENTS_MAX = 200;
* plan changes, and otherwise no more often than this.
*/
const TEAMS_MIN_MS = 60 * 1000;
/**
* The selected service types nobody is following: their plans only matter to a
* dashboard that has switched to one, and they never move during a service, so
* they ride a lane slower than the plan itself.
*/
const OTHERS_MIN_MS = 45 * 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. */
@@ -147,6 +154,14 @@ function clearedState() {
items: [],
teams: [], // who is scheduled, by team (the Teams tile)
teamsError: '', // why the last team read failed, if it did
serviceTypes: [], // every selected service type id, highest priority first
/**
* The selected service types this ProdDash is NOT following right now,
* each with its own current plan. A dashboard can switch to one of these
* for itself; the live machinery (ProPresenter, the service clock, the
* timers) stays with the one above, which is the service actually running.
*/
others: {},
live: clearedLive(),
service: clearedService(),
timing: null, // the admin's service-timing choices (no secrets)
@@ -610,7 +625,20 @@ module.exports = {
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();
/**
* Every service type this ProdDash follows, highest priority first. A
* config written before 1.10.0 has the single `serviceTypeId` instead;
* it is read as a list of one so a booth keeps working untouched until
* somebody opens the admin page.
*/
const serviceTypes = (Array.isArray(config.serviceTypes) ? config.serviceTypes : [])
.map((v) => String(v).trim())
.filter(Boolean);
if (!serviceTypes.length && String(config.serviceTypeId || '').trim()) {
serviceTypes.push(String(config.serviceTypeId).trim());
}
/** The one whose plan is on screen — everything plan-scoped hangs off this. */
let activeTypeId = serviceTypes[0] || '';
const pinnedPlanId = String(config.planId || '').trim();
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000;
const follow = config.followProPresenter !== false;
@@ -647,8 +675,9 @@ module.exports = {
let state = clearedState();
state.configured = pco.configured;
state.serviceTypeSet = Boolean(serviceTypeId);
state.serviceType = { id: serviceTypeId, name: '' };
state.serviceTypeSet = Boolean(serviceTypes.length);
state.serviceType = { id: activeTypeId, name: '' };
state.serviceTypes = serviceTypes.slice();
state.live.following = follow;
state.timing = { ...timing };
@@ -704,8 +733,37 @@ module.exports = {
/* — plan reading — */
function planPath(planId) {
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
function planPath(planId, stId = activeTypeId) {
return `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans/${encodeURIComponent(planId)}`;
}
/** id → name, asked once each. A service type that has gone is named, not fatal. */
const typeNames = new Map();
async function typeName(stId) {
if (typeNames.has(stId)) return typeNames.get(stId);
let name = `Service type ${stId}`;
try {
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}`);
name = String(st?.data?.attributes?.name || '').trim() || name;
} catch (err) {
if (err.status !== 404) throw err;
name = `Service type ${stId} (no longer in Planning Center)`;
}
typeNames.set(stId, name);
return name;
}
/**
* A pinned plan, as the admin's Plan select writes it: "<serviceType>/<plan>".
* A bare id is a pin saved before 1.10.0, when there was only one service
* type to belong to.
*/
function parsePin(raw) {
const text = String(raw || '').trim();
if (!text) return null;
const slash = text.indexOf('/');
if (slash > 0) return { stId: text.slice(0, slash), planId: text.slice(slash + 1) };
return { stId: serviceTypes[0] || '', planId: text };
}
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
@@ -740,8 +798,61 @@ module.exports = {
return pickPlan(await listPlans(stId));
}
async function readPlanTimes(planId) {
const body = await pco.getAll(`${planPath(planId)}/plan_times`);
/**
* The plan to follow, across every selected service type. Their plans are
* ranked together — one happening today beats one still to come, which
* beats one already past — and a dead heat on the date is settled by the
* order the service types are listed in, which is what that order is for.
*
* One service type failing (a deleted id, say) must not cost the booth the
* others, so it is logged and skipped; a credentials or network failure is
* not skippable and is thrown on.
*/
async function choosePlanAcross() {
const now = Date.now();
const today = localYmd(new Date(now));
const candidates = [];
for (let priority = 0; priority < serviceTypes.length; priority += 1) {
const stId = serviceTypes[priority];
let plans;
try {
plans = await listPlans(stId);
} catch (err) {
if (err.status === 401 || err.status === 429 || err.network) throw err;
log(`service type ${stId} skipped: ${err?.message || err}`);
continue;
}
for (const plan of plans) {
const tier = localYmd(new Date(plan.sortDate)) === today ? 0 : (plan.sortDate > now ? 1 : 2);
candidates.push({ ...plan, stId, priority, tier });
}
}
if (!candidates.length) return null;
candidates.sort((a, b) => (
a.tier - b.tier
// today and upcoming: soonest first. Past: most recent first.
|| (a.tier === 2 ? b.sortDate - a.sortDate : a.sortDate - b.sortDate)
|| a.priority - b.priority
));
return candidates[0];
}
/** Point everything plan-scoped at a service type, forgetting the last one's. */
async function useServiceType(stId) {
if (!stId || stId === activeTypeId) {
if (stId && !state.serviceType.name) state.serviceType = { id: stId, name: await typeName(stId) };
return;
}
activeTypeId = stId;
// Note categories belong to the service type; the team lists are already
// cached per type and look after themselves.
noteCategories = { at: 0, options: [] };
state.serviceType = { id: stId, name: await typeName(stId) };
if (serviceTypes.length > 1) log(`following service type "${state.serviceType.name}"`);
}
async function readPlanTimes(planId, stId = activeTypeId) {
const body = await pco.getAll(`${planPath(planId, stId)}/plan_times`);
return (body.data || [])
.map((t) => ({
id: String(t.id),
@@ -754,8 +865,8 @@ module.exports = {
.sort((x, y) => x.startsAt - y.startsAt);
}
async function readItems(planId) {
const target = `${planPath(planId)}/items`;
async function readItems(planId, stId = activeTypeId) {
const target = `${planPath(planId, stId)}/items`;
try {
return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
} catch (err) {
@@ -775,18 +886,21 @@ module.exports = {
* 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: [] };
const serviceTeamsByType = new Map(); // stId -> { at, list }
async function readServiceTeams() {
async function readServiceTeams(stId = activeTypeId) {
let serviceTeams = serviceTeamsByType.get(stId) || { at: 0, list: [] };
serviceTeamsByType.set(stId, serviceTeams);
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 body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(stId)}/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 };
serviceTeamsByType.set(stId, serviceTeams);
} catch (err) {
log(`service type team list read failed: ${err?.message || err}`);
serviceTeams.at = Date.now(); // don't retry every poll
@@ -795,7 +909,7 @@ module.exports = {
}
/** planPersonId → photo URL, so a browser never learns the CDN address. */
let teamPhotos = new Map();
const teamPhotos = new Map();
let teamsReadAt = 0;
/**
@@ -804,8 +918,8 @@ module.exports = {
* 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`;
async function readTeams(planId, stId = activeTypeId) {
const target = `${planPath(planId, stId)}/team_members`;
let body;
try {
body = await pco.getAll(target, { include: 'team,person' });
@@ -817,16 +931,16 @@ module.exports = {
throw err;
}
}
const teams = shapeTeams(body, await readServiceTeams());
const photos = new Map();
const teams = shapeTeams(body, await readServiceTeams(stId));
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);
// Photos accumulate: a dashboard looking at another service type still
// asks this module for its people's faces.
if (url) teamPhotos.set(String(pp.id), url);
}
teamPhotos = photos;
teamsReadAt = Date.now();
return teams;
}
@@ -854,6 +968,57 @@ module.exports = {
}
}
/**
* The other selected service types, each with its own current plan, for a
* dashboard that has switched to one. Read on the slow lane and wrapped
* individually: a service type that fails here is reported in its own
* entry and costs the booth nothing else.
*/
let othersReadAt = 0;
async function readOtherTypes(force) {
if (!force && Date.now() - othersReadAt < OTHERS_MIN_MS) return;
othersReadAt = Date.now();
const others = {};
for (const stId of serviceTypes) {
if (stId === activeTypeId) continue;
const entry = { id: stId, name: '', plan: null, items: [], teams: [], teamsError: '', lastError: '' };
others[stId] = entry;
try {
entry.name = await typeName(stId);
const chosen = await choosePlanId(stId);
if (!chosen) continue;
const [planTimes, items] = await Promise.all([
readPlanTimes(chosen.id, stId),
readItems(chosen.id, stId),
]);
const plan = shapePlan(chosen, stId, planTimes);
// No service clock runs for a type nobody is following, so take the
// service that is current or next purely to lay the items out.
const times = serviceTimes(planTimes);
const now = Date.now();
const service = times.find((t) => (t.endsAt || t.startsAt + DEFAULT_SERVICE_LENGTH_MS) > now) || times[times.length - 1];
if (service) {
plan.serviceStartsAt = service.startsAt;
plan.serviceEndsAt = service.endsAt || 0;
plan.serviceName = service.name || '';
}
scheduleItems(items, plan.serviceStartsAt || plan.sortDate);
entry.plan = plan;
entry.items = items;
try {
entry.teams = await readTeams(chosen.id, stId);
} catch (err) {
entry.teamsError = err?.message || String(err);
}
} catch (err) {
entry.lastError = err?.message || String(err);
log(`service type ${stId}: ${entry.lastError}`);
}
}
state.others = others;
}
/** 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 })) };
@@ -861,23 +1026,22 @@ module.exports = {
async function readPlan() {
if (!pco.configured || !serviceTypeId) return;
if (!pco.configured || !serviceTypes.length) return;
let planRec = null;
if (pinnedPlanId) {
planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null;
const pin = parsePin(pinnedPlanId);
if (pin && pin.planId && pin.stId) {
await useServiceType(pin.stId);
planRec = (await pco.get(planPath(pin.planId, pin.stId)))?.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;
const chosen = await choosePlanAcross();
if (chosen) {
await useServiceType(chosen.stId);
// 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 at once.
planRec = (await pco.get(planPath(chosen.rec.id, chosen.stId)))?.data || chosen.rec;
} else {
// Nothing anywhere: still name a service type so the tile can say whose.
await useServiceType(serviceTypes[0]);
}
}
if (!planRec) {
@@ -890,10 +1054,11 @@ module.exports = {
state.lastError = '';
state.lastFetched = Date.now();
syncServiceSafe(Date.now(), null);
await readOtherTypes(true);
return;
}
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
const plan = shapePlan(planRec, serviceTypeId, planTimes);
const plan = shapePlan(planRec, activeTypeId, 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`);
@@ -916,6 +1081,8 @@ module.exports = {
// Service selection, item schedule and the running clock all follow
// from the plan's service times — decided in one place.
syncServiceSafe(Date.now(), null);
// Whatever else is selected, for a dashboard that has switched to it.
await readOtherTypes(planChanged);
}
/** Say, once per plan, which item a document named like the plan will land on. */
@@ -943,11 +1110,11 @@ module.exports = {
* list — the gear window then says there is nothing to choose from yet.
*/
async function noteCategoryOptions() {
if (!pco.configured || !serviceTypeId) return [];
if (!pco.configured || !activeTypeId) 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 body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(activeTypeId)}/item_note_categories`);
const seen = new Set();
const options = (body.data || [])
.filter((c) => !c?.attributes?.deleted_at)
@@ -1713,11 +1880,11 @@ module.exports = {
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) {
} else if (!serviceTypes.length) {
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` : ''}`);
log(`reading ${serviceTypes.length} service type${serviceTypes.length === 1 ? '' : 's'} (${serviceTypes.join(', ')}) from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
schedulePoll(0);
}
log(
@@ -1777,6 +1944,7 @@ module.exports = {
},
listPlans,
pickPlan,
typeName,
timersSnapshot,
documentOptions,
noteCategoryOptions,
@@ -1805,11 +1973,11 @@ module.exports = {
},
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 (!serviceTypes.length) return { status: 'error', message: 'No service type selected — tick one in the Service types list below' };
if (!state.reachable) {
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
}
const name = state.serviceType.name || serviceTypeId;
const name = state.serviceType.name || activeTypeId;
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') : '';
@@ -1986,8 +2154,15 @@ module.exports = {
}
return names.join(' › ');
};
const live = current.getState().serviceType?.id || '';
const options = serviceTypes
.map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) }))
.map((st) => ({
value: st.id,
// Which one's plan is actually on screen, so the list says so
// rather than leaving it to be worked out.
label: st.id === live ? `${st.name} — following now` : 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) {
@@ -1997,40 +2172,46 @@ module.exports = {
},
/**
* 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.
* Options for the admin page's "Plan" select, across every service type
* ticked above (?serviceTypes=a,b,c), grouped by which one each belongs
* to. A value is "<serviceType>/<plan>", because a plan id alone does
* not say whose it is once there is more than one list.
*/
'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 asked = String(params.get('serviceTypes') || '').split(',').map((v) => v.trim()).filter(Boolean);
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' });
if (!pco.configured || !asked.length) return json(res, 200, { options: [auto] });
const options = [auto];
let failure = '';
for (const stId of asked) {
try {
const [name, plans] = await Promise.all([
current.typeName(stId),
current.listPlans(stId, { future: 8, past: 6 }),
]);
const picked = current.pickPlan(plans);
const now = Date.now();
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 += ' (this one, on Automatic)';
options.push({ value: `${stId}/${rec.id}`, label, group: name });
}
} catch (err) {
failure = err?.message || String(err);
}
json(res, 200, { options });
} catch (err) {
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) });
}
json(res, 200, failure && options.length === 1 ? { options, error: failure } : { options });
},
};
},
+48
View File
@@ -744,3 +744,51 @@
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
/* ── the service-type menu, and a tile looking at another one ────────── */
[data-module="pco-plan"] .pp-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 9px;
background: none;
border: none;
border-radius: 6px;
color: var(--text);
font-size: 12px;
font-family: inherit;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
[data-module="pco-plan"] .pp-menu-item:hover { background: var(--border); }
[data-module="pco-plan"] .pp-menu-tick {
width: 12px;
flex: none;
color: var(--accent);
font-weight: 700;
visibility: hidden;
}
[data-module="pco-plan"] .pp-menu-item.is-on .pp-menu-tick { visibility: visible; }
[data-module="pco-plan"] .pp-menu-name { flex: 1; min-width: 0; font-weight: 600; }
[data-module="pco-plan"] .pp-menu-note {
flex: none;
font-size: 0.8em;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
/* Looking at a service type other than the one running: the top bar carries
a quiet stripe so nobody reads this as the live plan. */
[data-module="pco-plan"] .pp-wrap.is-other-type .pp-head,
[data-module="pco-plan"] .tm-wrap.is-other-type .tm-head {
background: color-mix(in srgb, var(--warn) 9%, transparent);
border-bottom-color: color-mix(in srgb, var(--warn) 45%, transparent);
}
[data-module="pco-plan"] .pk-wrap.is-other-type {
box-shadow: inset 0 2px 0 color-mix(in srgb, var(--warn) 45%, transparent);
}
+25 -13
View File
@@ -13,11 +13,15 @@ const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
export default function createTeams({ root, moduleApi }) {
let state = null;
let feedOffline = false;
let stream = null;
let lastKey = '';
let view = viewOf(null);
let offViewed = null;
root.innerHTML = `
<div class="tm-wrap">
@@ -39,6 +43,8 @@ export default function createTeams({ root, moduleApi }) {
/* ── title-bar controls ─────────────────────────────────────────── */
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
const refreshBtn = moduleApi.header.addButton({
icon: REFRESH_SVG,
title: 'Re-read from Planning Center',
@@ -92,7 +98,7 @@ export default function createTeams({ root, moduleApi }) {
/* ── which teams and people to show ─────────────────────────────── */
function visibleTeams() {
const teams = Array.isArray(state?.teams) ? state.teams : [];
const teams = Array.isArray(view.teams) ? view.teams : [];
const wantTeams = chosen('teams');
const out = [];
for (const team of teams) {
@@ -118,8 +124,8 @@ export default function createTeams({ root, moduleApi }) {
if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
if (state.teamsError) return moduleApi.setStatus('error', `Team list: ${state.teamsError}`);
const people = (state.teams || []).reduce((n, t) => n + t.members.length, 0);
if (view.teamsError) return moduleApi.setStatus('error', `Team list: ${view.teamsError}`);
const people = (view.teams || []).reduce((n, t) => n + t.members.length, 0);
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
}
@@ -127,14 +133,14 @@ export default function createTeams({ root, moduleApi }) {
/** The counts describe what this tile shows, not what the plan holds. */
function templateValues() {
const plan = state?.plan || {};
const plan = view.plan || {};
const teams = visibleTeams();
const people = teams.reduce((n, t) => n + t.members.length, 0);
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
return {
series: plan.seriesTitle || '',
part: plan.title || '',
servicetype: state?.serviceType?.name || '',
servicetype: view.name || '',
date: plan.dates || '',
shortdate: plan.shortDates || plan.dates || '',
time: plan.serviceStartsAt
@@ -171,11 +177,11 @@ export default function createTeams({ root, moduleApi }) {
}
function renderHead() {
const show = on('showTopBar') && state?.plan;
const show = on('showTopBar') && view.plan;
headEl.hidden = !show;
if (!show) return;
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
titleEl.textContent = line1 || line2 || state.serviceType?.name || 'Teams';
titleEl.textContent = line1 || line2 || view.name || 'Teams';
subEl.textContent = line1 ? line2 || '' : '';
subEl.hidden = !subEl.textContent;
}
@@ -199,19 +205,19 @@ export default function createTeams({ root, moduleApi }) {
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.reachable && !state.plan) {
if (!state.reachable && !view.plan) {
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
}
if (!state.plan) {
if (!view.plan) {
return {
title: 'No plan found',
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans.`,
hint: `${view.name || 'This service type'} has no upcoming or recent plans.`,
};
}
if (!(state.teams || []).length) {
if (!(view.teams || []).length) {
return {
title: 'Nobody scheduled yet',
hint: state.teamsError || 'This plan has no team members in Planning Center.',
hint: view.teamsError || 'This plan has no team members in Planning Center.',
};
}
if (!visibleTeams().length) {
@@ -372,7 +378,7 @@ export default function createTeams({ root, moduleApi }) {
// Rebuild only when the roster or the settings actually changed: the
// avatars are <img>s and a needless rebuild makes them flicker.
const teams = visibleTeams();
const key = JSON.stringify([teams, cfg()]);
const key = JSON.stringify([view.id, teams, cfg()]);
if (key === lastKey) return;
lastKey = key;
bodyEl.innerHTML = '';
@@ -380,7 +386,10 @@ export default function createTeams({ root, moduleApi }) {
}
function render() {
view = viewOf(state);
switcher.sync();
wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
wrap.classList.toggle('header-gradient', on('headerGradient'));
renderStatus();
@@ -421,9 +430,12 @@ export default function createTeams({ root, moduleApi }) {
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
offViewed = onViewedType(() => { lastKey = ''; render(); });
render();
},
stop() {
offViewed?.();
offViewed = null;
stream?.close();
stream = null;
root.innerHTML = '';
+171
View File
@@ -0,0 +1,171 @@
/* PCO Plan — which service type THIS dashboard is looking at.
The module's server follows one service type at a time: whichever the
admin's priority order lands on. A dashboard may want a different one on
screen — the other campus, the youth plan — without changing what the
server follows or what any other screen sees.
That choice belongs to the browser, so it lives here: one value for the
whole page, kept in localStorage and shared between every PCO Plan tile on
it, so switching from one tile moves them all together. */
const KEY = 'pco-plan:viewed-service-type';
/** One bus per page, however many tiles import this. */
function bus() {
if (!window.__pcoPlanViewed) {
let value = '';
try {
value = String(localStorage.getItem(KEY) || '');
} catch {
value = ''; // private window, storage off: the choice just won't persist
}
window.__pcoPlanViewed = { value, listeners: new Set() };
}
return window.__pcoPlanViewed;
}
/** '' means "whatever the server is following" — the normal case. */
export function viewedType() {
return bus().value;
}
export function setViewedType(id) {
const b = bus();
const next = String(id || '');
if (next === b.value) return;
b.value = next;
try {
if (next) localStorage.setItem(KEY, next);
else localStorage.removeItem(KEY);
} catch { /* not persisted, still shared for this page */ }
for (const fn of [...b.listeners]) {
try {
fn(next);
} catch (err) {
console.error('[pco-plan] viewed-type listener failed:', err);
}
}
}
/** Returns an unsubscribe — call it from the tile's stop(). */
export function onViewedType(fn) {
const b = bus();
b.listeners.add(fn);
return () => b.listeners.delete(fn);
}
/**
* The plan a tile should draw, given the whole state and this page's choice:
* the one the server follows, or one of the others it also reads.
*
* `live` is false for anything but the followed one — the ProPresenter match,
* the service clock and the timers belong to the service actually running, so
* a tile looking elsewhere must not pretend to have them.
*/
export function viewOf(state) {
const empty = { id: '', name: '', plan: null, items: [], teams: [], teamsError: '', live: true, lastError: '' };
if (!state) return empty;
const wanted = viewedType();
const activeId = state.serviceType?.id || '';
if (!wanted || wanted === activeId) {
return {
id: activeId,
name: state.serviceType?.name || '',
plan: state.plan,
items: state.items || [],
teams: state.teams || [],
teamsError: state.teamsError || '',
live: true,
lastError: '',
};
}
const other = (state.others || {})[wanted];
if (!other) {
// Ticked off in admin since this dashboard chose it, or not read yet.
return { ...empty, id: wanted, live: false, lastError: 'That service type is no longer selected' };
}
return {
id: other.id,
name: other.name,
plan: other.plan,
items: other.items || [],
teams: other.teams || [],
teamsError: other.teamsError || '',
live: false,
lastError: other.lastError || '',
};
}
/** Every service type a dashboard may switch to, in the admin's priority order. */
export function choices(state) {
const out = [];
const activeId = state?.serviceType?.id || '';
for (const id of state?.serviceTypes || []) {
if (id === activeId) out.push({ id, name: state.serviceType?.name || id, following: true });
else out.push({ id, name: (state.others || {})[id]?.name || id, following: false });
}
return out;
}
const SWITCH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h13l-3-3"/><path d="M20 16H7l3 3"/></svg>';
/**
* The title-bar control that switches this dashboard between the selected
* service types. Added by every PCO Plan tile, because switching from any of
* them moves all of them; it hides itself whenever there is only one service
* type to choose from, which is the usual case.
*
* Returns { sync } — call it when the state arrives so the control appears,
* disappears or re-labels itself.
*/
export function addServiceTypeSwitch({ moduleApi, getState }) {
const menu = moduleApi.header.addMenu({
icon: SWITCH_SVG,
title: 'Service type shown on this dashboard',
build(el) {
el.classList.add('pp-menu');
const state = getState();
const wanted = viewedType();
const rows = [
{ id: '', name: 'Whatever is live', following: false, auto: true },
...choices(state),
];
for (const row of rows) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'pp-menu-item' + (row.id === wanted ? ' is-on' : '');
const tick = document.createElement('span');
tick.className = 'pp-menu-tick';
tick.textContent = '✓';
const name = document.createElement('span');
name.className = 'pp-menu-name';
name.textContent = row.name;
item.append(tick, name);
if (row.following) {
const note = document.createElement('span');
note.className = 'pp-menu-note';
note.textContent = 'live';
item.appendChild(note);
}
item.addEventListener('click', () => {
setViewedType(row.id);
menu.close();
});
el.appendChild(item);
}
},
});
return {
sync() {
const state = getState();
// One service type is no choice at all — rule 11, nothing in the bar
// that has nothing to do.
const many = (state?.serviceTypes || []).length > 1;
menu.button.hidden = !many;
const wanted = viewedType();
menu.button.classList.toggle('active', Boolean(wanted) && wanted !== (state?.serviceType?.id || ''));
},
};
}