From 20dc2deb2bf4c341d8b753eddd6c07584dfc1c65 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 10 Sep 2026 15:31:12 -0400 Subject: [PATCH] pco-plan: everything configured in Admin; key, section and top-bar restyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drops the separate setup page: the service type is a select grouped by Planning Center folder path (GET /service-types), the pinned plan a select of upcoming/recent plans that follows the chosen service type (GET /plans?serviceTypeId=), credentials sit in a group that folds once saved. - Show/hide moves from per-tile settings to a server-wide checklist in Admin (three columns, folded by default): top bar, series, part, service type, date, service time, total length, running clock, countdown, headers, item numbers, start times, length, type badge, key, arrangement, author/CCLI, description, notes, pre/post-service items, now indicator, item runtime, dim completed, ProPresenter line. Tiles keep only text size and auto-scroll. - Top bar text is a template with placeholders ({series} • {part} | {serviceType} • {date} by default; • separates droppable segments, | starts the second line). - Song key: larger, admin-chosen color (blue by default), always right after the title and before the type badge. Section headers are full-width accent bands. Optional background tint per item type. - tools/pco-mock.js plan titles are part numbers, as in a real series. Co-Authored-By: Claude Fable 5.1 --- README.md | 16 ++- modules/pco-plan/client.js | 259 ++++++++++++++++++++++------------- modules/pco-plan/module.json | 123 ++++++++++++----- modules/pco-plan/server.js | 122 ++++++++++------- modules/pco-plan/style.css | 96 +++++++++---- tools/pco-mock.js | 6 +- 6 files changed, 408 insertions(+), 214 deletions(-) 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 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, 400, { error: 'No Planning Center credentials — add them in /admin first.', unconfigured: true }); - } + 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`), @@ -838,44 +850,62 @@ module.exports = { folders.set(id, { id, name: `Folder ${id}`, parentId: '' }); } } - const state = current.getState(); - json(res, 200, { - folders: Array.from(folders.values()), - serviceTypes, - selected: state.serviceType.id, - selectedName: state.serviceType.name, - apiBase: API_BASE, - method: pco.method, - }); + // "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(`browse failed: ${err?.message || err}`); - json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) }); + log(`service type list failed: ${err?.message || err}`); + json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) }); } }, - /** Which plan would be followed for a service type — shown before saving. */ - 'GET /preview': async (req, res) => { + /** + * 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(); - if (!stId) return json(res, 400, { error: 'serviceTypeId is required.' }); + const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' }; + if (!pco.configured || !stId) return json(res, 200, { options: [auto] }); try { - const rec = await current.choosePlanId(stId); - if (!rec) return json(res, 200, { plan: null }); - const a = rec.attributes || {}; - json(res, 200, { - plan: { - id: String(rec.id), - title: String(a.title || ''), - seriesTitle: String(a.series_title || ''), - dates: String(a.dates || ''), - itemsCount: Number(a.items_count) || 0, - totalLength: Number(a.total_length) || 0, - sortDate: a.sort_date || '', - }, - }); + 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, { error: err?.message || String(err) }); + json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) }); } }, }; diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index 2d50675..c73c935 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -1,8 +1,10 @@ -/* PCO Plan module — scoped to its tiles, shell variables only. - Everything scales from --pp-size (set from the tile's text-size setting). */ +/* PCO Plan module — scoped to its tiles, shell variables only (plus the + admin-chosen --pp-key color for song keys). Everything scales from + --pp-size (set from the tile's text-size setting). */ [data-module="pco-plan"] .pp-wrap { --pp-size: 14px; + --pp-key: #4ea1ff; position: absolute; inset: 0; display: flex; @@ -11,7 +13,7 @@ color: var(--text); } -/* ── plan header ── */ +/* ── top bar ── */ [data-module="pco-plan"] .pp-head { flex: none; @@ -28,8 +30,9 @@ [data-module="pco-plan"] .pp-plan { flex: 1; min-width: 0; } [data-module="pco-plan"] .pp-plan-title { - font-size: 1.05em; + font-size: 1.1em; font-weight: 700; + line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -37,12 +40,13 @@ [data-module="pco-plan"] .pp-plan-sub { margin-top: 0.15em; - font-size: 0.8em; + font-size: 0.82em; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +[data-module="pco-plan"] .pp-plan-sub[hidden] { display: none; } [data-module="pco-plan"] .pp-clock { flex: none; @@ -91,22 +95,36 @@ [data-module="pco-plan"] .pp-row + .pp-row { margin-top: 0.15em; } [data-module="pco-plan"] .pp-row:hover { background: color-mix(in srgb, var(--text) 4%, transparent); } +/* Optional background tint per item type (admin "Background color per item type"). */ +[data-module="pco-plan"] .tint-types .pp-row.is-song { background: color-mix(in srgb, var(--accent) 9%, transparent); } +[data-module="pco-plan"] .tint-types .pp-row.is-media { background: color-mix(in srgb, var(--warn) 10%, transparent); } +[data-module="pco-plan"] .tint-types .pp-row.is-item { background: color-mix(in srgb, var(--text) 5%, transparent); } +[data-module="pco-plan"] .tint-types .pp-row.is-song:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); } +[data-module="pco-plan"] .tint-types .pp-row.is-media:hover { background: color-mix(in srgb, var(--warn) 15%, transparent); } +[data-module="pco-plan"] .tint-types .pp-row.is-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); } + +/* Section headers: a full-width band so a new part of the service is + unmistakable at a glance — accent bar, tinted ground, spaced capitals. */ [data-module="pco-plan"] .pp-row.is-header { - margin-top: 0.6em; - padding-top: 0.5em; - padding-bottom: 0.15em; - border-top: 1px solid var(--border); - border-radius: 0; - background: none; + margin-top: 0.9em; + padding: 0.5em 0.7em 0.45em; + align-items: center; + border-left: 3px solid var(--accent); + border-radius: 0.4em; + background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 22%, transparent), color-mix(in srgb, var(--accent) 5%, transparent) 70%, transparent); } -[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0; border-top: none; } +[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0.15em; } +[data-module="pco-plan"] .pp-row.is-header:hover { background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 26%, transparent), color-mix(in srgb, var(--accent) 7%, transparent) 70%, transparent); } [data-module="pco-plan"] .pp-row.is-header .pp-title { - font-size: 0.78em; + font-size: 0.85em; font-weight: 800; - letter-spacing: 0.12em; + letter-spacing: 0.16em; text-transform: uppercase; - color: var(--muted); + color: var(--text); } +[data-module="pco-plan"] .pp-row.is-header .pp-col-time, +[data-module="pco-plan"] .pp-row.is-header .pp-col-num, +[data-module="pco-plan"] .pp-row.is-header .pp-col-len { visibility: hidden; } [data-module="pco-plan"] .pp-row.is-pre .pp-title, [data-module="pco-plan"] .pp-row.is-post .pp-title { color: color-mix(in srgb, var(--text) 75%, var(--muted)); } @@ -116,10 +134,21 @@ [data-module="pco-plan"] .pp-row.is-current { border-left-color: var(--accent); - background: color-mix(in srgb, var(--accent) 14%, transparent); + background: color-mix(in srgb, var(--accent) 16%, transparent); opacity: 1; } +[data-module="pco-plan"] .pp-col-num { + flex: none; + width: 1.6em; + padding-top: 0.1em; + font-size: 0.8em; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--muted); + text-align: right; +} + [data-module="pco-plan"] .pp-col-time { flex: none; width: 4.6em; @@ -130,14 +159,13 @@ text-align: right; white-space: nowrap; } -[data-module="pco-plan"] .pp-col-time[hidden] { display: none; } [data-module="pco-plan"] .pp-row.is-current .pp-col-time { color: var(--accent); font-weight: 700; } [data-module="pco-plan"] .pp-col-main { flex: 1; min-width: 0; } [data-module="pco-plan"] .pp-title-line { display: flex; - align-items: baseline; + align-items: center; flex-wrap: wrap; gap: 0.2em 0.55em; min-width: 0; @@ -149,6 +177,21 @@ overflow-wrap: anywhere; } +/* The song key: right after the title, larger and in the admin-chosen + color (blue by default) so the band can read it from across the booth. */ +[data-module="pco-plan"] .pp-key { + font-size: 1.15em; + font-weight: 800; + line-height: 1.15; + letter-spacing: 0.02em; + padding: 0.05em 0.5em; + border-radius: 0.4em; + color: var(--pp-key); + background: color-mix(in srgb, var(--pp-key) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--pp-key) 45%, transparent); + white-space: nowrap; +} + [data-module="pco-plan"] .pp-now-tag { display: none; padding: 0.05em 0.45em; @@ -159,12 +202,10 @@ font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; - vertical-align: middle; } [data-module="pco-plan"] .pp-row.is-current .pp-now-tag { display: inline-block; } [data-module="pco-plan"] .pp-badge, -[data-module="pco-plan"] .pp-key, [data-module="pco-plan"] .pp-arr { font-size: 0.7em; font-weight: 700; @@ -180,13 +221,17 @@ } [data-module="pco-plan"] .pp-badge.is-song { color: color-mix(in srgb, var(--accent) 80%, var(--text)); border-color: color-mix(in srgb, var(--accent) 40%, transparent); } [data-module="pco-plan"] .pp-badge.is-media { color: color-mix(in srgb, var(--warn) 85%, var(--text)); border-color: color-mix(in srgb, var(--warn) 40%, transparent); } -[data-module="pco-plan"] .pp-key { - background: color-mix(in srgb, var(--text) 10%, transparent); - color: var(--text); - text-transform: none; -} [data-module="pco-plan"] .pp-arr { color: var(--muted); text-transform: none; letter-spacing: 0; font-weight: 600; } +[data-module="pco-plan"] .pp-author { + margin-top: 0.1em; + font-size: 0.78em; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + [data-module="pco-plan"] .pp-desc { margin-top: 0.15em; font-size: 0.86em; @@ -230,7 +275,6 @@ font-variant-numeric: tabular-nums; white-space: nowrap; } -[data-module="pco-plan"] .pp-col-len[hidden] { display: none; } [data-module="pco-plan"] .pp-len { font-size: 0.85em; color: var(--muted); diff --git a/tools/pco-mock.js b/tools/pco-mock.js index 4142087..73da9c2 100644 --- a/tools/pco-mock.js +++ b/tools/pco-mock.js @@ -84,9 +84,9 @@ function plansFor(stId) { ], }); return [ - mk(`${stId}-P1`, firstServiceStart - 7 * DAY, 'The Table', 'Living Hope'), - mk(`${stId}-P2`, firstServiceStart, 'Living Hope Wk 2', 'Living Hope'), - mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Living Hope Wk 3', 'Living Hope'), + mk(`${stId}-P1`, firstServiceStart - 7 * DAY, 'Part 1', 'Living Hope'), + mk(`${stId}-P2`, firstServiceStart, 'Part 2', 'Living Hope'), + mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Part 3', 'Living Hope'), ]; }