diff --git a/README.md b/README.md
index ea8c71f..2e7cf07 100644
--- a/README.md
+++ b/README.md
@@ -137,13 +137,15 @@ ignored from then on.
ProPresenter endpoint (host : port), password.
- **PCO Plan** — the order of service from Planning Center Services: every
plan item with its scheduled start time, length, type, song key,
- description and item notes (each detail is a per-tile show/hide setting).
- When the ProPresenter module is running it follows that module's live
- item, highlights the matching plan item, dims what's done and shows the
- elapsed vs planned runtime and how far ahead/behind the service is. Admin
- config: Planning Center credentials (Personal Access Token app ID + secret,
- or an OAuth token) and the service type — browse your Planning Center
- folders and pick it at `/modules/pco-plan/setup.html`.
+ description and item notes. When the ProPresenter module is running it
+ follows that module's live item, highlights the matching plan item, dims
+ what's done and shows the elapsed vs planned runtime and how far
+ ahead/behind the service is. Everything is configured in `/admin`:
+ Planning Center credentials (Personal Access Token app ID + secret, or an
+ OAuth token), the service type (a list grouped by your Planning Center
+ folders), an optional pinned plan, the top-bar text (placeholders such as
+ `{series} • {part} | {serviceType} • {date}`), colors, and a show/hide
+ checklist for every detail. Tiles only choose their text size.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js
index 0eb2666..a883ed4 100644
--- a/modules/pco-plan/client.js
+++ b/modules/pco-plan/client.js
@@ -2,10 +2,11 @@
The server part reads Planning Center and (optionally) follows the
ProPresenter module's live item; this tile only renders the plan it is
- streamed. Which details show is a per-tile choice (gear menu). */
+ streamed. What is shown is decided server-wide in /admin (moduleApi.config:
+ show/hide checklist, top-bar template, colors); the tile itself only owns
+ its text size and auto-scroll. */
const REFRESH_SVG = '';
-const SETUP_SVG = '';
export default function create({ root, moduleApi }) {
let state = null;
@@ -42,7 +43,10 @@ export default function create({ root, moduleApi }) {
const listEl = root.querySelector('.pp-list');
const footEl = root.querySelector('.pp-foot');
- const prefs = () => moduleApi.instanceSettings;
+ /** Admin-wide display settings (the show/hide checklist lives there). */
+ const cfg = () => moduleApi.config || {};
+ const on = (key) => cfg()[key] !== false; // unset = shown
+ const tilePrefs = () => moduleApi.instanceSettings;
const serverNow = () => Date.now() + skew;
/* ── title-bar controls ─────────────────────────────────────────── */
@@ -59,21 +63,13 @@ export default function create({ root, moduleApi }) {
});
function bumpTextSize(delta) {
- const size = Number(prefs().textSize) || 14;
+ const size = Number(tilePrefs().textSize) || 14;
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
applyPrefs();
}
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
- moduleApi.header.addButton({
- icon: SETUP_SVG,
- title: 'Planning Center setup (choose the service type)',
- onClick() {
- window.open(`/modules/${moduleApi.id}/setup.html`, '_blank', 'noopener');
- },
- });
-
/* ── formatting ─────────────────────────────────────────────────── */
function fmtClock(ms) {
@@ -103,26 +99,30 @@ export default function create({ root, moduleApi }) {
switch (itemType) {
case 'song': return 'Song';
case 'media': return 'Media';
- case 'header': return '';
default: return '';
}
}
+ function plannedLength() {
+ return (state?.items || [])
+ .filter((it) => it.servicePosition === 'during')
+ .reduce((sum, it) => sum + (it.length || 0), 0);
+ }
+
/* ── which items to show ────────────────────────────────────────── */
function visibleItems() {
- const p = prefs();
const items = Array.isArray(state?.items) ? state.items : [];
return items.filter((it) => {
- if (it.itemType === 'header' && !p.showHeaders) return false;
- if (it.servicePosition === 'pre' && !p.showPreService) return false;
- if (it.servicePosition === 'post' && !p.showPostService) return false;
+ if (it.itemType === 'header' && !on('showHeaders')) return false;
+ if (it.servicePosition === 'pre' && !on('showPreService')) return false;
+ if (it.servicePosition === 'post' && !on('showPostService')) return false;
return true;
});
}
function noteFilter() {
- const raw = String(prefs().noteCategories || '');
+ const raw = String(cfg().noteCategories || '');
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
}
@@ -133,7 +133,7 @@ export default function create({ root, moduleApi }) {
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
- if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — use the setup page');
+ if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
@@ -147,22 +147,65 @@ export default function create({ root, moduleApi }) {
moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`);
}
- /* ── plan header ────────────────────────────────────────────────── */
+ /* ── top bar: admin template with placeholders ──────────────────── */
+
+ const DEFAULT_TEMPLATE = '{series} • {part} | {serviceType} • {date}';
+
+ /** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
+ function templateValues() {
+ const plan = state?.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 || '' : '',
+ date: on('showDate') ? plan.dates || '' : '',
+ shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
+ time: on('showServiceTime') && plan.serviceStartsAt
+ ? `${plan.serviceName && plan.serviceName !== 'Service' ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`
+ : '',
+ length: on('showTotalLength') && planned ? fmtMinutes(planned) : '',
+ items: items.filter((it) => it.itemType !== 'header').length ? String(items.filter((it) => it.itemType !== 'header').length) : '',
+ };
+ }
+
+ /**
+ * "{series} • {part} | {serviceType} • {date}" → up to two lines. Within a
+ * line, • (or ·) separates segments; a segment whose placeholders all came
+ * out empty is dropped, so a plan with no series just reads "Wk 2".
+ */
+ function renderTemplate(template, values) {
+ const lines = String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2);
+ return lines.map((line) => {
+ const segments = line.split(/\s*[•·]\s*/);
+ const kept = [];
+ for (const seg of segments) {
+ let sawVar = false;
+ let allEmpty = true;
+ const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
+ sawVar = true;
+ const v = values[name.toLowerCase()];
+ if (v) allEmpty = false;
+ return v || '';
+ }).trim();
+ if (sawVar && allEmpty) continue;
+ if (text) kept.push(text);
+ }
+ return kept.join(' • ');
+ });
+ }
function renderHead() {
const plan = state?.plan;
- const show = prefs().showPlanHeader && plan;
+ const show = on('showTopBar') && plan;
headEl.hidden = !show;
if (!show) return;
- const title = [plan.dates, plan.title || plan.seriesTitle].filter(Boolean).join(' · ');
- planTitleEl.textContent = title || (state.serviceType?.name || 'Plan');
- const bits = [];
- if (state.serviceType?.name) bits.push(state.serviceType.name);
- if (plan.title && plan.seriesTitle) bits.push(plan.seriesTitle);
- if (plan.serviceStartsAt) bits.push(`${plan.serviceName ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`);
- const planned = state.items.filter((it) => it.servicePosition === 'during').reduce((sum, it) => sum + (it.length || 0), 0);
- if (planned) bits.push(`planned ${fmtMinutes(planned)}`);
- planSubEl.textContent = bits.join(' · ');
+ const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
+ planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan';
+ planSubEl.textContent = line1 ? line2 || '' : '';
+ planSubEl.hidden = !planSubEl.textContent;
renderClock();
}
@@ -174,16 +217,13 @@ export default function create({ root, moduleApi }) {
clockValueEl.className = 'pp-clock-value';
if (!plan || headEl.hidden) return;
const now = serverNow();
- if (prefs().showRuntime && live.serviceStartedAt) {
+ if (on('showRunningClock') && live.following && live.serviceStartedAt) {
clockLabelEl.textContent = 'Running';
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000);
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
- const planned = state.items
- .filter((it) => live.history?.[it.id]?.endedAt)
- .reduce((sum, it) => sum + (it.length || 0), 0);
- const actual = state.items
- .filter((it) => live.history?.[it.id]?.endedAt)
- .reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
+ const done = state.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;
if (planned && Math.abs(drift) >= 30) {
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
@@ -191,12 +231,12 @@ export default function create({ root, moduleApi }) {
}
return;
}
- if (plan.serviceStartsAt && plan.serviceStartsAt > now) {
+ if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) {
clockLabelEl.textContent = 'Starts in';
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
return;
}
- if (plan.serviceStartsAt) {
+ if (on('showRunningClock') && plan.serviceStartsAt) {
clockLabelEl.textContent = 'Since start';
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
}
@@ -210,15 +250,15 @@ export default function create({ root, moduleApi }) {
if (!state.configured) {
return {
title: 'Planning Center not connected',
- hint: 'Add a Planning Center Application ID and Secret (Personal Access Token) for PCO Plan in Admin.',
+ hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.serviceTypeSet) {
return {
title: 'No service type selected',
- hint: 'Browse your Planning Center folders and choose the service type to follow.',
- link: { href: `/modules/${moduleApi.id}/setup.html`, text: 'Open setup' },
+ hint: 'Pick the service type to follow from the list under PCO Plan in Admin.',
+ link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.reachable && !state.plan) {
@@ -230,22 +270,32 @@ export default function create({ root, moduleApi }) {
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
};
}
- if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings.' };
+ if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by the PCO Plan settings in Admin.' };
return null;
}
- function buildRow(item, p, notesWanted) {
+ function buildRow(item, number, notesWanted) {
const row = document.createElement('div');
row.className = 'pp-row';
row.dataset.id = item.id;
if (item.itemType === 'header') row.classList.add('is-header');
+ else row.classList.add(`is-${item.itemType === 'song' || item.itemType === 'media' ? item.itemType : 'item'}`);
if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`);
+ const isHeader = item.itemType === 'header';
- const timeCol = document.createElement('div');
- timeCol.className = 'pp-col-time';
- timeCol.textContent = p.showStartTimes && item.startsAt && item.itemType !== 'header' ? fmtClock(item.startsAt) : '';
- timeCol.hidden = !p.showStartTimes;
- row.appendChild(timeCol);
+ if (on('showItemNumbers')) {
+ const numCol = document.createElement('div');
+ numCol.className = 'pp-col-num';
+ numCol.textContent = isHeader ? '' : String(number);
+ row.appendChild(numCol);
+ }
+
+ if (on('showStartTimes')) {
+ const timeCol = document.createElement('div');
+ timeCol.className = 'pp-col-time';
+ timeCol.textContent = item.startsAt && !isHeader ? fmtClock(item.startsAt) : '';
+ row.appendChild(timeCol);
+ }
const main = document.createElement('div');
main.className = 'pp-col-main';
@@ -255,24 +305,27 @@ export default function create({ root, moduleApi }) {
titleEl.className = 'pp-title';
titleEl.textContent = item.title || item.song?.title || '(untitled)';
titleLine.appendChild(titleEl);
- if (item.itemType !== 'header') {
- const nowTag = document.createElement('span');
- nowTag.className = 'pp-now-tag';
- nowTag.textContent = 'Now';
- titleLine.appendChild(nowTag);
- if (p.showItemType && typeLabel(item.itemType)) {
- const badge = document.createElement('span');
- badge.className = `pp-badge is-${item.itemType}`;
- badge.textContent = typeLabel(item.itemType);
- titleLine.appendChild(badge);
- }
- if (p.showKey && item.keyName) {
+ if (!isHeader) {
+ // Order after the title: key, NOW, type badge, arrangement.
+ if (on('showKey') && item.keyName) {
const key = document.createElement('span');
key.className = 'pp-key';
key.textContent = item.keyName;
titleLine.appendChild(key);
}
- if (p.showArrangement && item.arrangement) {
+ if (on('showNowIndicator')) {
+ const nowTag = document.createElement('span');
+ nowTag.className = 'pp-now-tag';
+ nowTag.textContent = 'Now';
+ titleLine.appendChild(nowTag);
+ }
+ if (on('showItemType') && typeLabel(item.itemType)) {
+ const badge = document.createElement('span');
+ badge.className = `pp-badge is-${item.itemType}`;
+ badge.textContent = typeLabel(item.itemType);
+ titleLine.appendChild(badge);
+ }
+ if (on('showArrangement') && item.arrangement) {
const arr = document.createElement('span');
arr.className = 'pp-arr';
arr.textContent = item.arrangement;
@@ -281,13 +334,19 @@ export default function create({ root, moduleApi }) {
}
main.appendChild(titleLine);
- if (p.showDescription && item.description) {
+ if (!isHeader && on('showSongAuthor') && item.song && (item.song.author || item.song.ccli)) {
+ const author = document.createElement('div');
+ author.className = 'pp-author';
+ author.textContent = [item.song.author, item.song.ccli ? `CCLI ${item.song.ccli}` : ''].filter(Boolean).join(' · ');
+ main.appendChild(author);
+ }
+ if (on('showDescription') && item.description) {
const desc = document.createElement('div');
desc.className = 'pp-desc';
desc.textContent = item.description;
main.appendChild(desc);
}
- if (p.showNotes && item.notes?.length) {
+ if (on('showNotes') && item.notes?.length) {
const notes = item.notes.filter(notesWanted);
if (notes.length) {
const notesEl = document.createElement('div');
@@ -309,23 +368,25 @@ export default function create({ root, moduleApi }) {
}
row.appendChild(main);
- const lenCol = document.createElement('div');
- lenCol.className = 'pp-col-len';
- if (item.itemType !== 'header') {
- if (p.showLength) {
- const len = document.createElement('div');
- len.className = 'pp-len';
- len.textContent = item.length ? fmtDur(item.length) : '';
- lenCol.appendChild(len);
- }
- if (p.showRuntime && state?.live?.following) {
- const el = document.createElement('div');
- el.className = 'pp-elapsed';
- lenCol.appendChild(el);
+ const wantRuntime = on('showItemRuntime') && state?.live?.following;
+ if (on('showLength') || wantRuntime) {
+ const lenCol = document.createElement('div');
+ lenCol.className = 'pp-col-len';
+ if (!isHeader) {
+ if (on('showLength')) {
+ const len = document.createElement('div');
+ len.className = 'pp-len';
+ len.textContent = item.length ? fmtDur(item.length) : '';
+ lenCol.appendChild(len);
+ }
+ if (wantRuntime) {
+ const el = document.createElement('div');
+ el.className = 'pp-elapsed';
+ lenCol.appendChild(el);
+ }
}
+ row.appendChild(lenCol);
}
- lenCol.hidden = !p.showLength && !(p.showRuntime && state?.live?.following);
- row.appendChild(lenCol);
return row;
}
@@ -359,15 +420,18 @@ export default function create({ root, moduleApi }) {
return;
}
- // Rebuild rows only when the plan content or the display prefs changed;
- // live highlighting and timers are applied in place.
- const p = prefs();
- const key = JSON.stringify([state.plan.id, state.items, p, state.live?.following]);
+ // 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]);
if (key !== lastListKey) {
lastListKey = key;
listEl.innerHTML = '';
const notesWanted = noteFilter();
- for (const item of visibleItems()) listEl.appendChild(buildRow(item, p, notesWanted));
+ let number = 0;
+ for (const item of visibleItems()) {
+ if (item.itemType !== 'header') number += 1;
+ listEl.appendChild(buildRow(item, number, notesWanted));
+ }
lastCurrentId = null; // force the highlight + scroll pass
}
renderLiveMarks();
@@ -376,14 +440,11 @@ export default function create({ root, moduleApi }) {
/** Current / done classes and the elapsed timers, without rebuilding rows. */
function renderLiveMarks() {
const live = state?.live || {};
- const p = prefs();
const now = serverNow();
const currentId = live.following ? live.currentItemId || '' : '';
const history = live.history || {};
const byId = new Map(state.items.map((it) => [it.id, it]));
- const rows = listEl.querySelectorAll('.pp-row');
// Everything before the current item counts as done, matched or not.
- let seenCurrent = !currentId;
const doneIds = new Set();
if (currentId) {
for (const it of state.items) {
@@ -391,13 +452,12 @@ export default function create({ root, moduleApi }) {
if (it.itemType !== 'header') doneIds.add(it.id);
}
}
- for (const row of rows) {
+ const showNow = on('showNowIndicator');
+ for (const row of listEl.querySelectorAll('.pp-row')) {
const id = row.dataset.id;
const item = byId.get(id);
- const isCurrent = id === currentId;
- row.classList.toggle('is-current', isCurrent);
- row.classList.toggle('is-done', p.dimCompleted && doneIds.has(id));
- if (isCurrent) seenCurrent = true;
+ row.classList.toggle('is-current', showNow && id === currentId);
+ row.classList.toggle('is-done', on('dimCompleted') && doneIds.has(id));
const elapsedEl = row.querySelector('.pp-elapsed');
if (!elapsedEl || !item) continue;
const h = history[id];
@@ -415,10 +475,9 @@ export default function create({ root, moduleApi }) {
}
if (!h.endedAt) elapsedEl.classList.add('is-running');
}
- void seenCurrent;
if (currentId !== lastCurrentId) {
lastCurrentId = currentId;
- if (currentId && p.autoScroll && Date.now() - userScrolledAt > 8000) {
+ if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
@@ -429,7 +488,7 @@ export default function create({ root, moduleApi }) {
function renderFoot() {
const live = state?.live;
- const show = prefs().showLiveFooter && live?.following && state?.plan;
+ const show = on('showLiveFooter') && live?.following && state?.plan;
footEl.hidden = !show;
footEl.className = 'pp-foot';
if (!show) return;
@@ -466,6 +525,9 @@ export default function create({ root, moduleApi }) {
function render() {
wrap.classList.toggle('is-offline', feedOffline);
+ wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
+ const keyColor = String(cfg().keyColor || '').trim();
+ wrap.style.setProperty('--pp-key', /^[#a-zA-Z0-9(),.%\s-]+$/.test(keyColor) ? keyColor : '#4ea1ff');
renderStatus();
if (!state) {
headEl.hidden = true;
@@ -479,7 +541,7 @@ export default function create({ root, moduleApi }) {
}
function applyPrefs() {
- const size = Math.min(40, Math.max(9, Number(prefs().textSize) || 14));
+ const size = Math.min(40, Math.max(9, Number(tilePrefs().textSize) || 14));
wrap.style.setProperty('--pp-size', `${size}px`);
lastListKey = '';
render();
@@ -488,7 +550,7 @@ export default function create({ root, moduleApi }) {
function tick() {
if (!state?.plan) return;
renderClock();
- if (state.live?.following && prefs().showRuntime) renderLiveMarks();
+ if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
}
/* ── live feed ──────────────────────────────────────────────────── */
@@ -550,7 +612,8 @@ export default function create({ root, moduleApi }) {
root.innerHTML = '';
},
onConfigChange() {
- // The server re-inits and its stream re-sends the state; nothing to cache here.
+ // Display settings changed in Admin: re-render from the new
+ // moduleApi.config. The server re-inits too and re-sends its state.
lastListKey = '';
render();
},
diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json
index 7384df4..8e9c173 100644
--- a/modules/pco-plan/module.json
+++ b/modules/pco-plan/module.json
@@ -1,38 +1,58 @@
{
"id": "pco-plan",
"name": "PCO Plan",
- "version": "1.0.0",
- "description": "Order of service from Planning Center Services, following ProPresenter live. Pick the service type at /modules/pco-plan/setup.html",
+ "version": "1.1.0",
+ "description": "Order of service from Planning Center Services, following ProPresenter live",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 5, "h": 6 },
+ "configGroups": {
+ "Planning Center credentials": {
+ "collapsed": "whenSet",
+ "help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
+ },
+ "Show / hide": {
+ "collapsed": true,
+ "columns": 3,
+ "help": "Untick anything the tiles should not show. Applies to every PCO Plan tile."
+ }
+ },
"configSchema": {
"appId": {
"type": "string",
- "label": "Planning Center Application ID (Personal Access Token)",
- "default": ""
+ "label": "Application ID",
+ "default": "",
+ "group": "Planning Center credentials"
},
"secret": {
"type": "password",
- "label": "Planning Center Secret (Personal Access Token)",
- "default": ""
+ "label": "Secret",
+ "default": "",
+ "group": "Planning Center credentials"
},
"accessToken": {
"type": "password",
- "label": "OAuth 2.0 access token (alternative to App ID + Secret)",
- "default": ""
+ "label": "OAuth 2.0 access token (instead of Application ID + Secret)",
+ "default": "",
+ "group": "Planning Center credentials"
},
"serviceTypeId": {
- "type": "string",
- "label": "Service type ID — browse folders and pick one at /modules/pco-plan/setup.html",
- "default": ""
+ "type": "select",
+ "label": "Service type",
+ "default": "",
+ "optionsRoute": "/service-types",
+ "help": "Your Planning Center Services folders appear as groups in this list; pick the service type whose plans the tiles follow. Save the credentials first if the list is empty."
},
"planId": {
- "type": "string",
- "label": "Pin a plan ID (blank = today's plan, else the next upcoming)",
- "default": ""
+ "type": "select",
+ "label": "Plan",
+ "default": "",
+ "options": [{ "value": "", "label": "Automatic — today's plan, else the next upcoming" }],
+ "optionsRoute": "/plans",
+ "optionsDependsOn": "serviceTypeId",
+ "help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
},
"pollSeconds": {
"type": "number",
@@ -40,28 +60,63 @@
"default": 30
},
"followProPresenter": {
- "type": "boolean",
- "label": "Follow the ProPresenter module's live item to highlight the current plan item",
+ "type": "switch",
+ "label": "Follow the ProPresenter module's live item to highlight the current plan item and time it",
"default": true
- }
+ },
+ "noteCategories": {
+ "type": "string",
+ "label": "Item note categories to show (comma-separated, blank = all)",
+ "default": ""
+ },
+ "headerTemplate": {
+ "type": "string",
+ "label": "Top bar text",
+ "default": "{series} • {part} | {serviceType} • {date}",
+ "group": "Top bar",
+ "help": "Placeholders: {series} series name · {part} plan title / part number · {serviceType} · {date} · {shortDate} · {time} service start time · {length} planned length · {items} item count. Use • between items (an item whose placeholders are empty or hidden is dropped) and | to start a second line."
+ },
+ "tintByType": {
+ "type": "boolean",
+ "label": "Background color per item type (song / media / other)",
+ "default": false,
+ "group": "Top bar"
+ },
+ "keyColor": {
+ "type": "string",
+ "label": "Song key color",
+ "default": "#4ea1ff",
+ "group": "Top bar",
+ "help": "Any CSS color, e.g. #4ea1ff or dodgerblue."
+ },
+ "showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
+ "showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
+ "showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
+ "showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
+ "showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
+ "showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
+ "showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
+ "showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
+ "showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
+ "showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
+ "showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
+ "showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
+ "showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
+ "showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
+ "showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" },
+ "showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
+ "showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
+ "showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
+ "showNotes": { "type": "boolean", "label": "Item notes", "default": true, "group": "Show / hide" },
+ "showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
+ "showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
+ "showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
+ "showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
+ "dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
+ "showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
},
"instanceSchema": {
- "showPlanHeader": { "type": "boolean", "label": "Show plan title, date and service time", "default": true },
- "showStartTimes": { "type": "boolean", "label": "Show scheduled start time of each item", "default": true },
- "showLength": { "type": "boolean", "label": "Show planned length of each item", "default": true },
- "showItemType": { "type": "boolean", "label": "Show item type badge (song / media)", "default": true },
- "showKey": { "type": "boolean", "label": "Show song key", "default": true },
- "showArrangement": { "type": "boolean", "label": "Show song arrangement", "default": false },
- "showDescription": { "type": "boolean", "label": "Show item description", "default": true },
- "showNotes": { "type": "boolean", "label": "Show item notes", "default": true },
- "noteCategories": { "type": "string", "label": "Only these note categories (comma-separated, blank = all)", "default": "" },
- "showHeaders": { "type": "boolean", "label": "Show section headers", "default": true },
- "showPreService": { "type": "boolean", "label": "Show pre-service items", "default": true },
- "showPostService": { "type": "boolean", "label": "Show post-service items", "default": true },
- "showRuntime": { "type": "boolean", "label": "Show live runtime (elapsed vs planned) when following ProPresenter", "default": true },
- "showLiveFooter": { "type": "boolean", "label": "Show the ProPresenter match line at the bottom", "default": true },
- "dimCompleted": { "type": "boolean", "label": "Dim items that are already done", "default": true },
- "autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
- "textSize": { "type": "number", "label": "Text size (px)", "default": 14 }
+ "textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
+ "autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true }
}
}
diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js
index b6a13dd..0b3c937 100644
--- a/modules/pco-plan/server.js
+++ b/modules/pco-plan/server.js
@@ -10,8 +10,9 @@
* GET /state → { state, now } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
* POST /refresh → re-read the plan now
- * GET /browse → folders + service types, for the setup page
- * GET /preview?serviceTypeId=… → which plan the module would pick
+ * 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
*
* Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
* Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0
@@ -392,20 +393,24 @@ module.exports = {
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
}
- /** Today's plan, else the next upcoming one, else the most recent past one. */
- async function choosePlanId(stId) {
+ /** 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 [future, past] = await Promise.all([
- pco.get(base, { filter: 'future', order: 'sort_date', per_page: 5 }),
- pco.get(base, { filter: 'past', order: '-sort_date', per_page: 3 }),
+ const [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 [...(future?.data || []), ...(past?.data || [])]) {
+ 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 });
}
- const plans = Array.from(byId.values()).sort((x, y) => x.sortDate - y.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));
@@ -416,6 +421,10 @@ module.exports = {
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 || [])
@@ -694,7 +703,7 @@ module.exports = {
state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin';
log(state.lastError);
} else if (!serviceTypeId) {
- state.lastError = 'No service type selected — open /modules/pco-plan/setup.html';
+ 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` : ''}`);
@@ -725,7 +734,8 @@ module.exports = {
clearTimeout(pollTimer);
return poll();
},
- choosePlanId,
+ listPlans,
+ pickPlan,
};
return {
@@ -743,7 +753,7 @@ 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 — open /modules/pco-plan/setup.html' };
+ 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…' };
}
@@ -793,13 +803,15 @@ module.exports = {
);
},
- /** Folders + service types as flat lists; the setup page builds the tree. */
- 'GET /browse': async (req, res) => {
+ /**
+ * 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