From e7dc235a8a4f92880c43ad9223fbef8a0a2eadaf Mon Sep 17 00:00:00 2001 From: Waters Church Date: Wed, 9 Sep 2026 15:42:53 -0400 Subject: [PATCH 1/8] PCO Plan module: Planning Center order of service, following ProPresenter New module modules/pco-plan: reads the current plan for a chosen Planning Center Services service type (PAT or OAuth token), streams items with start times, lengths, keys, descriptions and item notes to tiles (every detail a per-tile show/hide setting), and follows the propresenter-now-next module's live item over loopback SSE to highlight the current plan item and track elapsed vs planned runtime. Ships its own setup page (/modules/pco-plan/setup.html) to browse folders and pick the service type through the shell admin API, plus tools/pco-mock.js for development. Co-Authored-By: Claude Fable 5.1 --- README.md | 15 +- docs/MODULE-GUIDE.md | 9 + modules/pco-plan/client.js | 558 +++++++++++++++++++++++ modules/pco-plan/module.json | 67 +++ modules/pco-plan/server.js | 825 +++++++++++++++++++++++++++++++++++ modules/pco-plan/setup.html | 372 ++++++++++++++++ modules/pco-plan/style.css | 312 +++++++++++++ tools/pco-mock.js | 281 ++++++++++++ 8 files changed, 2437 insertions(+), 2 deletions(-) create mode 100644 modules/pco-plan/client.js create mode 100644 modules/pco-plan/module.json create mode 100644 modules/pco-plan/server.js create mode 100644 modules/pco-plan/setup.html create mode 100644 modules/pco-plan/style.css create mode 100644 tools/pco-mock.js diff --git a/README.md b/README.md index 3cd53b3..ea8c71f 100644 --- a/README.md +++ b/README.md @@ -135,14 +135,25 @@ ignored from then on. and pick a selection via the tile's "Timers ▾" menu; overruns are unmistakably red. Read-only — no start/stop controls. Admin config: 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`. - **Clock** — a big booth clock; also the smallest possible module and the reference for module authors. To develop modules against mocks instead of real gear: `node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`), `node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on -`127.0.0.1:1599`) and `node tools/pp-timers-mock.js 1600` (ProPresenter -timers + timecode on `127.0.0.1:1600`). See the testing section of the +`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter +timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js` +(Planning Center Services on `127.0.0.1:24700` — run ProdDash with +`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the [module guide](docs/MODULE-GUIDE.md). ## Install as a desktop web app diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 6741fa1..674c231 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -462,6 +462,15 @@ client-side failures (a tile that fails to start says so in the tile). overrunning, stopped and elapsed timers plus an LTC timecode that periodically drops to no-signal (`--no-ltc` serves a ProPresenter without a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`. +- Planning Center: `node tools/pco-mock.js` (port 24700) — folders, service + types, plans (last week / today / next week), plan times and items with + songs, keys and item notes, behind a Basic/Bearer auth check. Planning + Center is a cloud API, so the module reads its base URL from the + environment instead of admin config: run ProdDash with + `PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any + Application ID + Secret in `/admin`, and pick "Sunday Service" in + `/modules/pco-plan/setup.html`. Its item titles match `pp-mock.js`, so + running both mocks exercises the ProPresenter matching. **The resilience checklist** — every module must pass this before it's done: diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js new file mode 100644 index 0000000..0eb2666 --- /dev/null +++ b/modules/pco-plan/client.js @@ -0,0 +1,558 @@ +/* PCO Plan — client part. + + 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). */ + +const REFRESH_SVG = ''; +const SETUP_SVG = ''; + +export default function create({ root, moduleApi }) { + let state = null; + let skew = 0; // server clock − local clock, ms + let feedOffline = false; + let stream = null; + let ticker = null; + let lastCurrentId = null; + let lastListKey = ''; + let userScrolledAt = 0; + + root.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
`; + + const wrap = root.querySelector('.pp-wrap'); + const headEl = root.querySelector('.pp-head'); + const planTitleEl = root.querySelector('.pp-plan-title'); + const planSubEl = root.querySelector('.pp-plan-sub'); + const clockLabelEl = root.querySelector('.pp-clock-label'); + const clockValueEl = root.querySelector('.pp-clock-value'); + const listEl = root.querySelector('.pp-list'); + const footEl = root.querySelector('.pp-foot'); + + const prefs = () => moduleApi.instanceSettings; + const serverNow = () => Date.now() + skew; + + /* ── title-bar controls ─────────────────────────────────────────── */ + + const refreshBtn = moduleApi.header.addButton({ + icon: REFRESH_SVG, + title: 'Re-read the plan from Planning Center now', + onClick() { + refreshBtn.classList.add('active'); + moduleApi.fetch('/refresh', { method: 'POST' }) + .catch(() => { /* the stream reports the outcome */ }) + .finally(() => refreshBtn.classList.remove('active')); + }, + }); + + function bumpTextSize(delta) { + const size = Number(prefs().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) { + if (!ms) return ''; + return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + } + + function fmtDur(totalSec, { signed = false } = {}) { + const sign = totalSec < 0 ? '-' : (signed ? '+' : ''); + const s = Math.abs(Math.round(totalSec)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h) return `${sign}${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; + return `${sign}${m}:${String(sec).padStart(2, '0')}`; + } + + function fmtMinutes(totalSec) { + const m = Math.round(totalSec / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + const rest = m % 60; + return rest ? `${h} h ${rest} min` : `${h} h`; + } + + function typeLabel(itemType) { + switch (itemType) { + case 'song': return 'Song'; + case 'media': return 'Media'; + case 'header': return ''; + default: return ''; + } + } + + /* ── 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; + return true; + }); + } + + function noteFilter() { + const raw = String(prefs().noteCategories || ''); + const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); + return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true; + } + + /* ── status dot ─────────────────────────────────────────────────── */ + + function renderStatus() { + 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.reachable) { + return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…'); + } + const live = state.live || {}; + let extra = ''; + if (live.following) { + extra = live.available + ? (live.ppReachable ? ' · following ProPresenter' : ' · ProPresenter unreachable') + : ` · ${live.reason || 'ProPresenter feed unavailable'}`; + } + moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`); + } + + /* ── plan header ────────────────────────────────────────────────── */ + + function renderHead() { + const plan = state?.plan; + const show = prefs().showPlanHeader && 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(' · '); + renderClock(); + } + + function renderClock() { + const plan = state?.plan; + const live = state?.live || {}; + clockLabelEl.textContent = ''; + clockValueEl.textContent = ''; + clockValueEl.className = 'pp-clock-value'; + if (!plan || headEl.hidden) return; + const now = serverNow(); + if (prefs().showRuntime && 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 drift = actual - planned; + if (planned && Math.abs(drift) >= 30) { + clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`; + clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead'); + } + return; + } + if (plan.serviceStartsAt && plan.serviceStartsAt > now) { + clockLabelEl.textContent = 'Starts in'; + clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000); + return; + } + if (plan.serviceStartsAt) { + clockLabelEl.textContent = 'Since start'; + clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000); + } + } + + /* ── the list ───────────────────────────────────────────────────── */ + + function emptyMessage() { + if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' }; + if (!state) return { title: 'Connecting…', hint: '' }; + 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.', + 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' }, + }; + } + if (!state.reachable && !state.plan) { + return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' }; + } + if (!state.plan) { + return { + title: 'No plan found', + 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.' }; + return null; + } + + function buildRow(item, p, notesWanted) { + const row = document.createElement('div'); + row.className = 'pp-row'; + row.dataset.id = item.id; + if (item.itemType === 'header') row.classList.add('is-header'); + if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`); + + 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); + + const main = document.createElement('div'); + main.className = 'pp-col-main'; + const titleLine = document.createElement('div'); + titleLine.className = 'pp-title-line'; + const titleEl = document.createElement('span'); + 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) { + const key = document.createElement('span'); + key.className = 'pp-key'; + key.textContent = item.keyName; + titleLine.appendChild(key); + } + if (p.showArrangement && item.arrangement) { + const arr = document.createElement('span'); + arr.className = 'pp-arr'; + arr.textContent = item.arrangement; + titleLine.appendChild(arr); + } + } + main.appendChild(titleLine); + + if (p.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) { + const notes = item.notes.filter(notesWanted); + if (notes.length) { + const notesEl = document.createElement('div'); + notesEl.className = 'pp-notes'; + for (const note of notes) { + const n = document.createElement('div'); + n.className = 'pp-note'; + if (note.category) { + const cat = document.createElement('span'); + cat.className = 'pp-note-cat'; + cat.textContent = note.category; + n.appendChild(cat); + } + n.appendChild(document.createTextNode(note.content)); + notesEl.appendChild(n); + } + main.appendChild(notesEl); + } + } + 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); + } + } + lenCol.hidden = !p.showLength && !(p.showRuntime && state?.live?.following); + row.appendChild(lenCol); + return row; + } + + function renderList() { + const empty = emptyMessage(); + if (empty) { + listEl.innerHTML = ''; + lastListKey = ''; + const box = document.createElement('div'); + box.className = 'pp-empty'; + const t = document.createElement('div'); + t.className = 'pp-empty-title'; + t.textContent = empty.title; + box.appendChild(t); + if (empty.hint) { + const h = document.createElement('div'); + h.className = 'pp-empty-hint'; + h.textContent = empty.hint; + box.appendChild(h); + } + if (empty.link) { + const a = document.createElement('a'); + a.className = 'pp-empty-link'; + a.href = empty.link.href; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = empty.link.text; + box.appendChild(a); + } + listEl.appendChild(box); + 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]); + if (key !== lastListKey) { + lastListKey = key; + listEl.innerHTML = ''; + const notesWanted = noteFilter(); + for (const item of visibleItems()) listEl.appendChild(buildRow(item, p, notesWanted)); + lastCurrentId = null; // force the highlight + scroll pass + } + renderLiveMarks(); + } + + /** 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) { + if (it.id === currentId) break; + if (it.itemType !== 'header') doneIds.add(it.id); + } + } + for (const row of rows) { + 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; + const elapsedEl = row.querySelector('.pp-elapsed'); + if (!elapsedEl || !item) continue; + const h = history[id]; + elapsedEl.className = 'pp-elapsed'; + if (!h) { + elapsedEl.textContent = ''; + continue; + } + const elapsedSec = ((h.endedAt || now) - h.startedAt) / 1000; + elapsedEl.textContent = fmtDur(elapsedSec); + if (item.length) { + const over = elapsedSec - item.length; + if (over > item.length * 0.25 + 30) elapsedEl.classList.add('is-over-hard'); + else if (over > 0) elapsedEl.classList.add('is-over'); + } + if (!h.endedAt) elapsedEl.classList.add('is-running'); + } + void seenCurrent; + if (currentId !== lastCurrentId) { + lastCurrentId = currentId; + if (currentId && p.autoScroll && Date.now() - userScrolledAt > 8000) { + const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`); + row?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + } + } + + /* ── footer: what ProPresenter is doing ─────────────────────────── */ + + function renderFoot() { + const live = state?.live; + const show = prefs().showLiveFooter && live?.following && state?.plan; + footEl.hidden = !show; + footEl.className = 'pp-foot'; + if (!show) return; + if (!live.available) { + footEl.classList.add('is-muted'); + footEl.textContent = live.reason || 'ProPresenter feed unavailable'; + return; + } + if (!live.ppEnabled) { + footEl.classList.add('is-muted'); + footEl.textContent = 'ProPresenter module has no host configured'; + return; + } + if (!live.ppReachable) { + footEl.classList.add('is-down'); + footEl.textContent = 'ProPresenter unreachable'; + return; + } + if (!live.activeName) { + footEl.classList.add('is-muted'); + footEl.textContent = 'Nothing live in ProPresenter'; + return; + } + if (live.matched) { + footEl.classList.add('is-live'); + footEl.textContent = `Live: ${live.activeName}`; + } else { + footEl.classList.add('is-unmatched'); + footEl.textContent = `Live: ${live.activeName} — not in the plan`; + } + } + + /* ── render + ticking ───────────────────────────────────────────── */ + + function render() { + wrap.classList.toggle('is-offline', feedOffline); + renderStatus(); + if (!state) { + headEl.hidden = true; + renderList(); + footEl.hidden = true; + return; + } + renderHead(); + renderList(); + renderFoot(); + } + + function applyPrefs() { + const size = Math.min(40, Math.max(9, Number(prefs().textSize) || 14)); + wrap.style.setProperty('--pp-size', `${size}px`); + lastListKey = ''; + render(); + } + + function tick() { + if (!state?.plan) return; + renderClock(); + if (state.live?.following && prefs().showRuntime) renderLiveMarks(); + } + + /* ── live feed ──────────────────────────────────────────────────── */ + + function accept(payload) { + if (!payload || typeof payload !== 'object' || !payload.state) return; + state = payload.state; + if (Number.isFinite(payload.now)) skew = payload.now - Date.now(); + feedOffline = false; + render(); + } + + function connect() { + stream = moduleApi.sse('/stream', { + open() { + feedOffline = false; + render(); + }, + error() { + feedOffline = true; + render(); + }, + events: { + state(ev) { + try { + accept(JSON.parse(ev.data)); + } catch { /* a bad frame is not fatal */ } + }, + }, + }); + } + + function onUserScroll() { + userScrolledAt = Date.now(); + } + + /* ── lifecycle ──────────────────────────────────────────────────── */ + + return { + start() { + applyPrefs(); + moduleApi.setStatus('connecting', 'Connecting…'); + moduleApi.fetch('/state') + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { if (body && !state) accept(body); }) + .catch(() => { /* the stream will cover it */ }); + connect(); + listEl.addEventListener('wheel', onUserScroll, { passive: true }); + listEl.addEventListener('touchmove', onUserScroll, { passive: true }); + ticker = setInterval(tick, 1000); + }, + stop() { + clearInterval(ticker); + ticker = null; + stream?.close(); + stream = null; + listEl.removeEventListener('wheel', onUserScroll); + listEl.removeEventListener('touchmove', onUserScroll); + root.innerHTML = ''; + }, + onConfigChange() { + // The server re-inits and its stream re-sends the state; nothing to cache here. + lastListKey = ''; + render(); + }, + }; +} diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json new file mode 100644 index 0000000..7384df4 --- /dev/null +++ b/modules/pco-plan/module.json @@ -0,0 +1,67 @@ +{ + "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", + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { "w": 3, "h": 3 }, + "defaultSize": { "w": 5, "h": 6 }, + "configSchema": { + "appId": { + "type": "string", + "label": "Planning Center Application ID (Personal Access Token)", + "default": "" + }, + "secret": { + "type": "password", + "label": "Planning Center Secret (Personal Access Token)", + "default": "" + }, + "accessToken": { + "type": "password", + "label": "OAuth 2.0 access token (alternative to App ID + Secret)", + "default": "" + }, + "serviceTypeId": { + "type": "string", + "label": "Service type ID — browse folders and pick one at /modules/pco-plan/setup.html", + "default": "" + }, + "planId": { + "type": "string", + "label": "Pin a plan ID (blank = today's plan, else the next upcoming)", + "default": "" + }, + "pollSeconds": { + "type": "number", + "label": "Re-read the plan every N seconds", + "default": 30 + }, + "followProPresenter": { + "type": "boolean", + "label": "Follow the ProPresenter module's live item to highlight the current plan item", + "default": true + } + }, + "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 } + } +} diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js new file mode 100644 index 0000000..8871acd --- /dev/null +++ b/modules/pco-plan/server.js @@ -0,0 +1,825 @@ +'use strict'; + +/** + * PCO Plan — server part. + * + * Owns the one Planning Center Services connection, keeps the current plan + * (order of service) for the configured service type in memory, and fans it + * out to every tile over SSE. Browsers never talk to Planning Center. + * + * GET /state → { state, now } (instant paint for new tiles) + * GET /stream → SSE, `state` events (change-detected, heartbeated) + * POST /refresh → re-read the plan now + * GET /browse → folders + service types, for the setup page + * GET /preview?serviceTypeId=… → which plan the module would pick + * + * Planning Center API v2 (https://api.planningcenteronline.com/services/v2): + * Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0 + * bearer token. Responses are JSON:API — `data`, `included`, `links.next`. + * GET /folders, /service_types (setup browsing) + * GET /service_types/{st}/plans?filter=future|past (pick the plan) + * GET /service_types/{st}/plans/{id} (title, dates…) + * GET …/plans/{id}/plan_times (service start times) + * GET …/plans/{id}/items?include=song,arrangement,item_notes + * + * Phase 2 — following ProPresenter: instead of talking to ProPresenter a + * second time, this module reads the propresenter-now-next module's own SSE + * feed over loopback (/api/modules/propresenter-now-next/stream) and matches + * the live presentation name to a plan item. Item start/end timestamps are + * tracked here, once, so every tile agrees on the runtime. + * + * PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at + * tools/pco-mock.js for development. + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_API_BASE = 'https://api.planningcenteronline.com'; +const API_BASE = String(process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); +const SERVICES = '/services/v2'; + +/** Abort a hung Planning Center request well before the poll cadence. */ +const REQUEST_TIMEOUT_MS = 12000; +/** Fastest allowed plan re-read (PCO rate limit is 100 requests / 20 s per app). */ +const MIN_POLL_S = 10; +/** Backoff after a failed plan read. */ +const RETRY_MS = 15000; +/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ +const HEARTBEAT_MS = 15000; +/** The Now/Next module whose feed we follow. */ +const NOW_NEXT_ID = 'propresenter-now-next'; +const NOW_NEXT_RETRY_MS = 3000; +const NOW_NEXT_MISSING_RETRY_MS = 15000; +/** Minimum name-similarity to call a ProPresenter document a plan item. */ +const MATCH_THRESHOLD = 0.6; + +/** Set by init(), read by the routes — both are rebuilt together on remount. */ +let current = null; + +/* ── small helpers ──────────────────────────────────────────────────── */ + +function clearedState() { + return { + configured: false, // credentials present + serviceTypeSet: false, // a service type is chosen + reachable: false, // last Planning Center read succeeded + lastError: '', + lastFetched: 0, + apiBase: API_BASE, + serviceType: { id: '', name: '' }, + plan: null, + items: [], + live: clearedLive(), + }; +} + +function clearedLive() { + return { + following: false, // admin switch on + available: false, // Now/Next feed reachable + reason: '', // why not available + ppEnabled: false, + ppReachable: false, + activeName: '', // what ProPresenter has live right now + matched: false, // activeName maps to a plan item + currentItemId: '', + currentScore: 0, + serviceStartedAt: 0, // first matched item start, ms epoch + history: {}, // itemId → { startedAt, endedAt } + }; +} + +/** The shell's port, so the loopback Now/Next feed lands on this ProdDash. */ +function shellPort() { + const env = Number(process.env.PORT); + if (env) return env; + try { + const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config', 'proddash.json'), 'utf8')); + if (Number(cfg.port)) return Number(cfg.port); + } catch { /* fall through */ } + return 24500; +} + +function localYmd(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */ +function cleanTitle(raw) { + const original = String(raw || '').trim(); + let s = original; + s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); + s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); + s = s.replace(/\s[-–—]+\s/g, ' '); + s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); + s = s.replace(/\s{2,}/g, ' ').trim(); + return s || original; +} + +function normalize(raw) { + return cleanTitle(raw) + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/[’'`]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']); +function tokens(norm) { + return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t))); +} + +/** 0..1 similarity between two titles. */ +function similarity(a, b) { + const na = normalize(a); + const nb = normalize(b); + if (!na || !nb) return 0; + if (na === nb) return 1; + if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85; + const ta = tokens(na); + const tb = tokens(nb); + if (!ta.size || !tb.size) return 0; + let common = 0; + for (const t of ta) if (tb.has(t)) common += 1; + return (2 * common) / (ta.size + tb.size); // Dice coefficient +} + +/* ── Planning Center client ─────────────────────────────────────────── */ + +function createPcoClient(config) { + const token = String(config.accessToken || '').trim(); + const appId = String(config.appId || '').trim(); + const secret = String(config.secret || ''); + let auth = ''; + if (token) auth = `Bearer ${token}`; + else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64'); + + async function get(target, params) { + const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target); + for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v)); + let response; + try { + response = await fetch(url, { + headers: { Authorization: auth, Accept: 'application/json' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err) { + const e = new Error(`Planning Center unreachable: ${err?.message || err}`); + e.network = true; + throw e; + } + if (!response.ok) { + let detail = ''; + try { + const body = await response.json(); + detail = body?.errors?.[0]?.detail || body?.errors?.[0]?.title || ''; + } catch { /* no JSON body */ } + const e = new Error( + response.status === 401 + ? 'Planning Center rejected the credentials (401)' + : `Planning Center ${response.status}${detail ? `: ${detail}` : ''}` + ); + e.status = response.status; + if (response.status === 429) e.retryAfter = Number(response.headers.get('retry-after')) || 10; + throw e; + } + return response.json(); + } + + /** Follow JSON:API `links.next` until the collection is complete. */ + async function getAll(target, params) { + const data = []; + const included = []; + let url = target; + let query = { per_page: 100, ...(params || {}) }; + for (let page = 0; page < 30; page += 1) { + const body = await get(url, query); + if (Array.isArray(body?.data)) data.push(...body.data); + if (Array.isArray(body?.included)) included.push(...body.included); + const next = body?.links?.next; + if (!next) break; + url = next; + query = {}; // `next` already carries the query string + } + return { data, included }; + } + + return { configured: Boolean(auth), method: token ? 'oauth' : 'pat', get, getAll }; +} + +/* ── plan shaping ───────────────────────────────────────────────────── */ + +function pickServiceTime(planTimes, nowMs) { + const services = planTimes.filter((t) => t.timeType === 'service' && t.startsAt); + const pool = services.length ? services : planTimes.filter((t) => t.startsAt); + if (!pool.length) return null; + pool.sort((a, b) => a.startsAt - b.startsAt); + // The service that is running or up next; after the last one, the last one. + const upcoming = pool.find((t) => (t.endsAt || t.startsAt + 90 * 60000) > nowMs); + return upcoming || pool[pool.length - 1]; +} + +/** Scheduled start of every item from the chosen service start time. */ +function scheduleItems(items, serviceStartMs) { + for (const item of items) item.startsAt = 0; + if (!serviceStartMs) return; + let t = serviceStartMs; + for (const item of items) { + if (item.servicePosition !== 'during') continue; + item.startsAt = t; + t += (item.length || 0) * 1000; + } + // Post-service items run on from the end of the service… + for (const item of items) { + if (item.servicePosition !== 'post') continue; + item.startsAt = t; + t += (item.length || 0) * 1000; + } + // …and pre-service items are counted backwards to end at the service start. + let back = serviceStartMs; + for (let i = items.length - 1; i >= 0; i -= 1) { + const item = items[i]; + if (item.servicePosition !== 'pre') continue; + back -= (item.length || 0) * 1000; + item.startsAt = back; + } +} + +function shapeItems(itemsBody) { + const included = new Map(); + for (const rec of itemsBody.included || []) included.set(`${rec.type}/${rec.id}`, rec); + const rel = (item, name) => item?.relationships?.[name]?.data; + const items = (itemsBody.data || []).map((it) => { + const a = it.attributes || {}; + const songRef = rel(it, 'song'); + const song = songRef ? included.get(`Song/${songRef.id}`) : null; + const arrRef = rel(it, 'arrangement'); + const arrangement = arrRef ? included.get(`Arrangement/${arrRef.id}`) : null; + const noteRefs = Array.isArray(rel(it, 'item_notes')) ? rel(it, 'item_notes') : []; + const notes = noteRefs + .map((ref) => included.get(`ItemNote/${ref.id}`)) + .filter(Boolean) + .map((n) => ({ + category: String(n.attributes?.category_name || ''), + content: String(n.attributes?.content || '').trim(), + })) + .filter((n) => n.content); + return { + id: String(it.id), + sequence: Number(a.sequence) || 0, + title: String(a.title || '').trim(), + itemType: String(a.item_type || 'item'), + length: Number(a.length) || 0, + servicePosition: String(a.service_position || 'during'), + description: String(a.description || '').trim(), + keyName: String(a.key_name || '').trim(), + song: song + ? { + title: String(song.attributes?.title || ''), + author: String(song.attributes?.author || ''), + ccli: song.attributes?.ccli_number ? String(song.attributes.ccli_number) : '', + } + : null, + arrangement: arrangement ? String(arrangement.attributes?.name || '') : '', + notes, + startsAt: 0, + }; + }); + items.sort((x, y) => x.sequence - y.sequence); + return items; +} + +function shapePlan(planRec, serviceTypeId, planTimes) { + const a = planRec?.attributes || {}; + return { + id: String(planRec?.id || ''), + serviceTypeId: String(serviceTypeId), + title: String(a.title || '').trim(), + seriesTitle: String(a.series_title || '').trim(), + dates: String(a.dates || '').trim(), + shortDates: String(a.short_dates || '').trim(), + sortDate: a.sort_date ? Date.parse(a.sort_date) || 0 : 0, + totalLength: Number(a.total_length) || 0, + itemsCount: Number(a.items_count) || 0, + url: String(a.planning_center_url || ''), + planTimes, + serviceStartsAt: 0, + serviceEndsAt: 0, + serviceName: '', + }; +} + +/* ── the module ─────────────────────────────────────────────────────── */ + +module.exports = { + init({ config, log }) { + const streams = new Set(); + const pco = createPcoClient(config); + const serviceTypeId = String(config.serviceTypeId || '').trim(); + const pinnedPlanId = String(config.planId || '').trim(); + const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000; + const follow = config.followProPresenter !== false; + + let state = clearedState(); + state.configured = pco.configured; + state.serviceTypeSet = Boolean(serviceTypeId); + state.serviceType = { id: serviceTypeId, name: '' }; + state.live.following = follow; + + let stopped = false; + let pollTimer = null; + let inFlight = null; + let lastFrame = ''; + + /* — broadcasting — */ + + function frameOf() { + return JSON.stringify(state); + } + + function broadcast(force) { + const frame = frameOf(); + if (!force && frame === lastFrame) return; + lastFrame = frame; + if (!streams.size) return; + const payload = `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`; + for (const res of streams) { + try { + res.write(payload); + } catch { + streams.delete(res); + } + } + } + + /* — plan reading — */ + + function planPath(planId) { + 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) { + 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 byId = new Map(); + for (const rec of [...(future?.data || []), ...(past?.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); + if (!plans.length) return null; + const now = Date.now(); + const today = localYmd(new Date(now)); + const todays = plans.find((p) => localYmd(new Date(p.sortDate)) === today); + if (todays) return todays.rec; + const next = plans.find((p) => p.sortDate > now); + if (next) return next.rec; + return plans[plans.length - 1].rec; + } + + async function readPlanTimes(planId) { + const body = await pco.getAll(`${planPath(planId)}/plan_times`); + return (body.data || []) + .map((t) => ({ + id: String(t.id), + name: String(t.attributes?.name || '').trim(), + timeType: String(t.attributes?.time_type || 'service'), + startsAt: Date.parse(t.attributes?.starts_at || '') || 0, + endsAt: Date.parse(t.attributes?.ends_at || '') || 0, + })) + .filter((t) => t.startsAt) + .sort((x, y) => x.startsAt - y.startsAt); + } + + async function readItems(planId) { + const target = `${planPath(planId)}/items`; + try { + return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' })); + } catch (err) { + // An account/API that refuses one of the includes still has items. + if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) { + log(`items include rejected (${err.message}) — reading items without includes`); + return shapeItems(await pco.getAll(target)); + } + throw err; + } + } + + async function readPlan() { + if (!pco.configured || !serviceTypeId) return; + let planRec = null; + if (pinnedPlanId) { + planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null; + } else { + const chosen = await choosePlanId(serviceTypeId); + // Re-read the chosen plan by id: the list endpoint's attributes are the + // same, but this keeps one code path and picks up edits immediately. + planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null; + } + if (!state.serviceType.name) { + try { + const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`); + state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') }; + } catch (err) { + if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`); + throw err; + } + } + if (!planRec) { + state.plan = null; + state.items = []; + state.reachable = true; + state.lastError = ''; + state.lastFetched = Date.now(); + return; + } + const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]); + const plan = shapePlan(planRec, serviceTypeId, planTimes); + const service = pickServiceTime(planTimes, Date.now()); + if (service) { + plan.serviceStartsAt = service.startsAt; + plan.serviceEndsAt = service.endsAt; + plan.serviceName = service.name; + } + scheduleItems(items, plan.serviceStartsAt || plan.sortDate); + if (state.plan?.id !== plan.id) { + if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title})`); + else log(`following plan ${plan.id}: ${plan.dates} ${plan.title || ''}`.trim()); + resetLiveTracking(); + } + state.plan = plan; + state.items = items; + state.reachable = true; + state.lastError = ''; + state.lastFetched = Date.now(); + } + + function schedulePoll(ms) { + if (stopped) return; + clearTimeout(pollTimer); + pollTimer = setTimeout(poll, ms); + pollTimer.unref?.(); + } + + async function poll() { + if (stopped) return; + if (inFlight) return inFlight; + inFlight = (async () => { + let delay = pollMs; + try { + await readPlan(); + } catch (err) { + state.reachable = false; + state.lastError = err?.message || String(err); + delay = err?.retryAfter ? (err.retryAfter + 1) * 1000 : RETRY_MS; + log(`plan read failed: ${state.lastError}`); + } + broadcast(); + schedulePoll(delay); + })(); + try { + await inFlight; + } finally { + inFlight = null; + } + } + + /* — phase 2: follow the ProPresenter Now/Next feed over loopback — */ + + let nnRequest = null; + let nnRetryTimer = null; + let lastLiveName = null; + + function resetLiveTracking() { + const keep = state.live; + state.live = { + ...clearedLive(), + following: keep.following, + available: keep.available, + reason: keep.reason, + ppEnabled: keep.ppEnabled, + ppReachable: keep.ppReachable, + activeName: keep.activeName, + }; + lastLiveName = null; + } + + /** Best plan item for a live presentation name, preferring the ones ahead of us. */ + function matchItem(name) { + const items = state.items.filter((it) => it.itemType !== 'header'); + if (!items.length) return null; + const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId); + let best = null; + for (let i = 0; i < items.length; i += 1) { + const item = items[i]; + const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0); + if (score < MATCH_THRESHOLD) continue; + // Distance ahead of the current item (wrapping so items behind rank last). + const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length; + if ( + !best + || score > best.score + 0.001 + || (Math.abs(score - best.score) <= 0.001 && dist < best.dist) + ) { + best = { item, score, dist }; + } + } + return best; + } + + function applyLiveView(view) { + const live = state.live; + live.available = true; + live.reason = ''; + live.ppEnabled = Boolean(view?.enabled); + live.ppReachable = Boolean(view?.reachable); + const activeItem = Array.isArray(view?.items) && view.activeIndex >= 0 ? view.items[view.activeIndex] : null; + const name = String(activeItem?.name || view?.activePresentationName || '').trim(); + live.activeName = name; + if (!name) { + // Cleared output between items is normal — the current item stays current. + lastLiveName = ''; + broadcast(); + return; + } + if (name === lastLiveName) return; + lastLiveName = name; + const hit = matchItem(name); + live.matched = Boolean(hit); + if (hit) { + live.currentScore = hit.score; + if (hit.item.id !== live.currentItemId) startItem(hit.item.id); + } + broadcast(); + } + + function startItem(itemId) { + const live = state.live; + const now = Date.now(); + if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) { + live.history[live.currentItemId].endedAt = now; + } + live.currentItemId = itemId; + live.history[itemId] = { startedAt: now, endedAt: 0 }; + if (!live.serviceStartedAt) live.serviceStartedAt = now; + const item = state.items.find((it) => it.id === itemId); + log(`live item → ${item?.title || itemId}`); + } + + function nowNextUnavailable(reason, retryMs) { + const live = state.live; + if (live.available || live.reason !== reason) { + live.available = false; + live.reason = reason; + live.ppReachable = false; + broadcast(); + } + if (stopped) return; + clearTimeout(nnRetryTimer); + nnRetryTimer = setTimeout(connectNowNext, retryMs); + nnRetryTimer.unref?.(); + } + + function connectNowNext() { + if (stopped || !follow) return; + const port = shellPort(); + const req = http.get( + { host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + const why = res.statusCode === 404 + ? 'ProPresenter module is disabled or not installed' + : `ProPresenter module feed answered ${res.statusCode}`; + return nowNextUnavailable(why, NOW_NEXT_MISSING_RETRY_MS); + } + res.setEncoding('utf8'); + let buffer = ''; + res.on('data', (chunk) => { + buffer += chunk; + let idx; + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const block = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + let event = 'message'; + let data = ''; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data += line.slice(5).trim(); + } + if (event !== 'state' || !data) continue; + try { + applyLiveView(JSON.parse(data)); + } catch { /* a bad frame is not fatal */ } + } + }); + res.on('end', () => nowNextUnavailable('ProPresenter module feed ended — reconnecting', NOW_NEXT_RETRY_MS)); + res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS)); + } + ); + req.on('error', (err) => nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS)); + req.setTimeout(45000, () => req.destroy(new Error('feed went silent'))); + nnRequest = req; + } + + /* — start — */ + + if (!pco.configured) { + state.lastError = 'No Planning Center credentials — add 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'; + log(state.lastError); + } else { + log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`); + schedulePoll(0); + } + if (follow) { + state.live.reason = 'Connecting to the ProPresenter module feed'; + connectNowNext(); + } + + const heartbeat = setInterval(() => { + for (const res of streams) { + try { + res.write(': ping\n\n'); + } catch { + streams.delete(res); + } + } + }, HEARTBEAT_MS); + heartbeat.unref?.(); + + current = { + streams, + pco, + getState: () => state, + refresh() { + clearTimeout(pollTimer); + return poll(); + }, + choosePlanId, + }; + + return { + stop() { + stopped = true; + clearInterval(heartbeat); + clearTimeout(pollTimer); + clearTimeout(nnRetryTimer); + try { nnRequest?.destroy(); } catch { /* gone */ } + for (const res of streams) { + try { res.end(); } catch { /* already gone */ } + } + streams.clear(); + if (current && current.streams === streams) current = null; + }, + health() { + if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' }; + if (!serviceTypeId) return { status: 'error', message: 'No service type selected — open /modules/pco-plan/setup.html' }; + if (!state.reachable) { + return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' }; + } + const name = state.serviceType.name || serviceTypeId; + const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found'; + const live = follow ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : ''; + return { status: 'ok', message: `Following ${name}${plan}${live}` }; + }, + }; + }, + + routes({ log }) { + const json = (res, status, body) => { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); + }; + + return { + 'GET /state': (req, res) => { + json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() }); + }, + + 'GET /stream': (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write('retry: 3000\n\n'); + const state = current ? current.getState() : clearedState(); + res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`); + if (!current) return void res.end(); + const { streams } = current; + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + streams.add(res); + req.on('close', () => streams.delete(res)); + }, + + 'POST /refresh': (req, res) => { + if (!current) return json(res, 503, { error: 'Module not running.' }); + current.refresh().then( + () => json(res, 200, { ok: true, state: current?.getState() }), + (err) => json(res, 502, { error: err?.message || String(err) }) + ); + }, + + /** Folders + service types as flat lists; the setup page builds the tree. */ + 'GET /browse': 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 }); + } + try { + const [folderBody, stBody] = await Promise.all([ + pco.getAll(`${SERVICES}/folders`), + pco.getAll(`${SERVICES}/service_types`), + ]); + const folders = new Map(); + for (const f of folderBody.data || []) { + folders.set(String(f.id), { + id: String(f.id), + name: String(f.attributes?.name || 'Folder'), + parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '', + }); + } + const serviceTypes = (stBody.data || []).map((st) => ({ + id: String(st.id), + name: String(st.attributes?.name || 'Service type'), + frequency: String(st.attributes?.frequency || ''), + parentId: st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '', + })); + // Fill in any folder the list did not include (some accounts only + // list root folders) by fetching the missing parents individually. + const missing = new Set(); + for (const st of serviceTypes) if (st.parentId && !folders.has(st.parentId)) missing.add(st.parentId); + for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) missing.add(f.parentId); + let guard = 0; + while (missing.size && guard < 50) { + const id = missing.values().next().value; + missing.delete(id); + guard += 1; + try { + const one = (await pco.get(`${SERVICES}/folders/${encodeURIComponent(id)}`))?.data; + const parentId = one?.relationships?.parent?.data?.id ? String(one.relationships.parent.data.id) : ''; + folders.set(id, { id, name: String(one?.attributes?.name || 'Folder'), parentId }); + if (parentId && !folders.has(parentId)) missing.add(parentId); + } catch { + folders.set(id, { id, name: `Folder ${id}`, parentId: '' }); + } + } + 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, + }); + } catch (err) { + log(`browse failed: ${err?.message || err}`); + json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) }); + } + }, + + /** Which plan would be followed for a service type — shown before saving. */ + 'GET /preview': async (req, res) => { + if (!current) return json(res, 503, { error: 'Module not running.' }); + const params = new URLSearchParams(req.search || ''); + const stId = String(params.get('serviceTypeId') || '').trim(); + if (!stId) return json(res, 400, { error: 'serviceTypeId is required.' }); + 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 || '', + }, + }); + } catch (err) { + json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) }); + } + }, + }; + }, +}; diff --git a/modules/pco-plan/setup.html b/modules/pco-plan/setup.html new file mode 100644 index 0000000..9593838 --- /dev/null +++ b/modules/pco-plan/setup.html @@ -0,0 +1,372 @@ + + + + + +PCO Plan — Planning Center setup + + + + +
+
+

PCO Plan · Planning Center setup

+ ← Admin + Dashboard +
+

Browse your Planning Center Services folders and choose the service type whose plans the PCO Plan tiles should follow.

+ +
+

Currently following

+
Loading…
+
+
+ +
+

Service types

+
+
Loading folders from Planning Center…
+
+ +
Credentials (Application ID + Secret from api.planningcenteronline.com, or an OAuth token) are entered under PCO Plan in Admin. Saving here re-initialises the module; open tiles pick up the new plan on their own.
+
+
+ + + + diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css new file mode 100644 index 0000000..2d50675 --- /dev/null +++ b/modules/pco-plan/style.css @@ -0,0 +1,312 @@ +/* PCO Plan module — scoped to its tiles, shell variables only. + Everything scales from --pp-size (set from the tile's text-size setting). */ + +[data-module="pco-plan"] .pp-wrap { + --pp-size: 14px; + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + font-size: var(--pp-size); + color: var(--text); +} + +/* ── plan header ── */ + +[data-module="pco-plan"] .pp-head { + flex: none; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1em; + padding: 0.6em 0.85em 0.5em; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--accent) 6%, transparent); +} +[data-module="pco-plan"] .pp-head[hidden] { display: none; } + +[data-module="pco-plan"] .pp-plan { flex: 1; min-width: 0; } + +[data-module="pco-plan"] .pp-plan-title { + font-size: 1.05em; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-module="pco-plan"] .pp-plan-sub { + margin-top: 0.15em; + font-size: 0.8em; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-module="pco-plan"] .pp-clock { + flex: none; + text-align: right; +} +[data-module="pco-plan"] .pp-clock-label { + font-size: 0.7em; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + white-space: nowrap; +} +[data-module="pco-plan"] .pp-clock-value { + font-size: 1.25em; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +[data-module="pco-plan"] .pp-clock-value:empty, +[data-module="pco-plan"] .pp-clock-label:empty { display: none; } +[data-module="pco-plan"] .pp-clock-value.is-behind { color: var(--warn); } +[data-module="pco-plan"] .pp-clock-value.is-ahead { color: var(--accent); } + +/* ── list ── */ + +[data-module="pco-plan"] .pp-list { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 0.35em 0.5em 0.6em; + overscroll-behavior: contain; + scroll-padding: 2em; +} + +[data-module="pco-plan"] .pp-row { + display: flex; + align-items: flex-start; + gap: 0.6em; + padding: 0.45em 0.5em; + border-radius: 0.5em; + border-left: 3px solid transparent; + transition: background 0.2s ease, opacity 0.3s ease; +} +[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); } + +[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; +} +[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0; border-top: none; } +[data-module="pco-plan"] .pp-row.is-header .pp-title { + font-size: 0.78em; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +[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)); } + +[data-module="pco-plan"] .pp-row.is-done { opacity: 0.45; } +[data-module="pco-plan"] .pp-row.is-done:hover { opacity: 0.8; } + +[data-module="pco-plan"] .pp-row.is-current { + border-left-color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + opacity: 1; +} + +[data-module="pco-plan"] .pp-col-time { + flex: none; + width: 4.6em; + padding-top: 0.1em; + font-size: 0.85em; + font-variant-numeric: tabular-nums; + color: var(--muted); + 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; + flex-wrap: wrap; + gap: 0.2em 0.55em; + min-width: 0; +} + +[data-module="pco-plan"] .pp-title { + font-weight: 600; + line-height: 1.3; + overflow-wrap: anywhere; +} + +[data-module="pco-plan"] .pp-now-tag { + display: none; + padding: 0.05em 0.45em; + border-radius: 999px; + background: var(--accent); + color: var(--bg); + font-size: 0.65em; + 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; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.1em 0.45em; + border-radius: 0.35em; + white-space: nowrap; +} +[data-module="pco-plan"] .pp-badge { + color: var(--muted); + border: 1px solid var(--border); +} +[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-desc { + margin-top: 0.15em; + font-size: 0.86em; + line-height: 1.35; + color: color-mix(in srgb, var(--text) 80%, var(--muted)); + white-space: pre-line; + overflow-wrap: anywhere; +} + +[data-module="pco-plan"] .pp-notes { + margin-top: 0.3em; + display: flex; + flex-direction: column; + gap: 0.2em; +} +[data-module="pco-plan"] .pp-note { + font-size: 0.82em; + line-height: 1.35; + padding: 0.25em 0.5em; + border-radius: 0.35em; + background: color-mix(in srgb, var(--warn) 9%, transparent); + border-left: 2px solid color-mix(in srgb, var(--warn) 60%, transparent); + color: var(--text); + white-space: pre-line; + overflow-wrap: anywhere; +} +[data-module="pco-plan"] .pp-note-cat { + display: inline-block; + margin-right: 0.5em; + font-size: 0.8em; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--warn); +} + +[data-module="pco-plan"] .pp-col-len { + flex: none; + min-width: 3.2em; + text-align: right; + 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); + padding-top: 0.1em; +} +[data-module="pco-plan"] .pp-elapsed { + font-size: 0.9em; + font-weight: 700; + color: var(--text); + opacity: 0.8; +} +[data-module="pco-plan"] .pp-elapsed:empty { display: none; } +[data-module="pco-plan"] .pp-elapsed.is-running { color: var(--accent); opacity: 1; } +[data-module="pco-plan"] .pp-elapsed.is-over { color: var(--warn); opacity: 1; } +[data-module="pco-plan"] .pp-elapsed.is-over-hard { color: var(--danger); opacity: 1; } + +/* ── empty / degraded states ── */ + +[data-module="pco-plan"] .pp-empty { + padding: 12% 1.2em 1em; + text-align: center; + color: var(--muted); +} +[data-module="pco-plan"] .pp-empty-title { + font-size: 1.1em; + font-weight: 700; + color: var(--text); +} +[data-module="pco-plan"] .pp-empty-hint { + margin-top: 0.4em; + font-size: 0.9em; + line-height: 1.4; +} +[data-module="pco-plan"] .pp-empty-link { + display: inline-block; + margin-top: 0.9em; + padding: 0.35em 0.9em; + border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent); + border-radius: 999px; + color: var(--accent); + font-weight: 600; + text-decoration: none; +} +[data-module="pco-plan"] .pp-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); } + +[data-module="pco-plan"] .pp-wrap.is-offline .pp-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); } +[data-module="pco-plan"] .pp-wrap.is-offline .pp-list { opacity: 0.6; } + +/* ── footer: ProPresenter match line ── */ + +[data-module="pco-plan"] .pp-foot { + flex: none; + display: flex; + align-items: center; + gap: 0.5em; + padding: 0.4em 0.85em; + border-top: 1px solid var(--border); + font-size: 0.8em; + font-weight: 600; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="pco-plan"] .pp-foot[hidden] { display: none; } +[data-module="pco-plan"] .pp-foot::before { + content: ''; + flex: none; + width: 0.55em; + height: 0.55em; + border-radius: 999px; + background: var(--border); +} +[data-module="pco-plan"] .pp-foot.is-live { color: var(--text); } +[data-module="pco-plan"] .pp-foot.is-live::before { background: var(--accent); box-shadow: 0 0 0 0.2em color-mix(in srgb, var(--accent) 18%, transparent); } +[data-module="pco-plan"] .pp-foot.is-unmatched { color: var(--warn); } +[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); } +[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); } +[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); } diff --git a/tools/pco-mock.js b/tools/pco-mock.js new file mode 100644 index 0000000..4142087 --- /dev/null +++ b/tools/pco-mock.js @@ -0,0 +1,281 @@ +/** + * Mock Planning Center Services API for developing the pco-plan module + * without a Planning Center account. + * + * node tools/pco-mock.js [port] (default 24700) + * PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js + * + * Serves JSON:API shapes matching api.planningcenteronline.com/services/v2: + * GET /services/v2/folders nested folders (parent rel) + * GET /services/v2/folders/{id} + * GET /services/v2/service_types every service type, with its + * parent folder relationship + * GET /services/v2/service_types/{id} + * GET /services/v2/service_types/{id}/plans ?filter=future|past, order, + * per_page — today's plan, + * next week's and last week's + * GET …/plans/{id} + * GET …/plans/{id}/plan_times rehearsal + two services; the + * first service starts 90 s + * after the mock boots so the + * "Starts in" state is visible + * GET …/plans/{id}/items?include=… items + Song / Arrangement / + * ItemNote compound documents + * + * Item titles deliberately match band-lineup-display/tmp/pp-mock.js's + * playlist, so running both mocks exercises the ProPresenter matching. + * + * Any request without an Authorization header (Basic or Bearer) gets a + * Planning-Center-style 401, so a missing/blank credential shows up the way + * it would in production. Every request is logged. + */ +'use strict'; + +const http = require('http'); + +const PORT = Number.parseInt(process.argv[2] || '24700', 10); +const bootedAt = Date.now(); + +/* ── data ───────────────────────────────────────────────────────────── */ + +const FOLDERS = [ + { id: '100', name: 'Campuses', parentId: '' }, + { id: '110', name: 'North Campus', parentId: '100' }, + { id: '120', name: 'Downtown', parentId: '100' }, + { id: '200', name: 'Archive', parentId: '' }, +]; + +const SERVICE_TYPES = [ + { id: '1001', name: 'Sunday Service', frequency: 'Every week', parentId: '110' }, + { id: '1002', name: 'Wednesday Night', frequency: 'Every week', parentId: '110' }, + { id: '1003', name: 'Downtown Sunday', frequency: 'Every week', parentId: '120' }, + { id: '1004', name: 'Christmas Eve 2024', frequency: 'Every 12 months', parentId: '200' }, + { id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' }, +]; + +function iso(ms) { + return new Date(ms).toISOString(); +} + +function longDate(ms) { + return new Date(ms).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); +} + +function shortDate(ms) { + return new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +const DAY = 86400000; +const firstServiceStart = bootedAt + 90 * 1000; + +/** Plans for service type 1001: last week, today, next week. */ +function plansFor(stId) { + if (stId !== '1001' && stId !== '1003') return []; + const mk = (id, serviceStart, title, series) => ({ + id, + serviceTypeId: stId, + title, + series, + sortDate: serviceStart, + times: [ + { id: `${id}-r`, name: 'Rehearsal', type: 'rehearsal', start: serviceStart - 60 * 60000, end: serviceStart - 15 * 60000 }, + { id: `${id}-s1`, name: 'First Service', type: 'service', start: serviceStart, end: serviceStart + 75 * 60000 }, + { id: `${id}-s2`, name: 'Second Service', type: 'service', start: serviceStart + 120 * 60000, end: serviceStart + 195 * 60000 }, + ], + }); + 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'), + ]; +} + +function planRecord(p) { + const items = ITEMS.filter((it) => it.type !== 'header'); + return { + type: 'Plan', + id: p.id, + attributes: { + title: p.title, + series_title: p.series, + dates: longDate(p.sortDate), + short_dates: shortDate(p.sortDate), + sort_date: iso(p.sortDate), + total_length: items.reduce((s, it) => s + it.length, 0), + items_count: ITEMS.length, + plan_notes_count: 0, + planning_center_url: `https://services.planningcenteronline.com/plans/${p.id}`, + }, + relationships: { service_type: { data: { type: 'ServiceType', id: p.serviceTypeId } } }, + links: { self: `/services/v2/service_types/${p.serviceTypeId}/plans/${p.id}` }, + }; +} + +// Titles match pp-mock's playlist: Welcome, This Is The Day, Sermon: Living +// Hope, Bumper Video, Closing Song, Benediction. +const ITEMS = [ + { id: 'i1', type: 'header', title: 'Pre-Service', pos: 'pre', length: 0 }, + { id: 'i2', type: 'media', title: 'Countdown', pos: 'pre', length: 300, desc: 'Start 5 min before', notes: [['Video', 'Loop the countdown from 8:55']] }, + { id: 'i3', type: 'header', title: 'Worship', pos: 'during', length: 0 }, + { id: 'i4', type: 'song', title: 'This Is The Day', pos: 'during', length: 270, key: 'G', song: { id: 's1', title: 'This Is The Day', author: 'Fred Hammond' }, arr: 'Default Arrangement', notes: [['Audio', 'Lead vocal: Sarah'], ['Lighting', 'Bright — house at 40%']] }, + { id: 'i5', type: 'song', title: 'Goodness of God', pos: 'during', length: 320, key: 'A', song: { id: 's2', title: 'Goodness of God', author: 'Bethel Music', ccli: '7117726' }, arr: 'Acoustic', notes: [['ProPresenter', 'Use the [ Full ] arrangement']] }, + { id: 'i6', type: 'item', title: 'Welcome', pos: 'during', length: 180, desc: 'Pastor Mike — mention Fall Kickoff', notes: [['Audio', 'Handheld 2 hot']] }, + { id: 'i7', type: 'media', title: 'Bumper Video', pos: 'during', length: 60, notes: [['Video', 'Program to Playback A']] }, + { id: 'i8', type: 'header', title: 'Message', pos: 'during', length: 0 }, + { id: 'i9', type: 'item', title: 'Sermon: Living Hope', pos: 'during', length: 2100, desc: '1 Peter 1:3-9', notes: [['ProPresenter', 'Sermon slides from PCO'], ['Lighting', 'Stage wash, house at 20%']] }, + { id: 'i10', type: 'song', title: 'Closing Song', pos: 'during', length: 240, key: 'D', song: { id: 's3', title: 'Build My Life', author: 'Housefires' }, arr: 'Default Arrangement' }, + { id: 'i11', type: 'item', title: 'Benediction', pos: 'during', length: 90 }, + { id: 'i12', type: 'header', title: 'Post-Service', pos: 'post', length: 0 }, + { id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] }, +]; + +function itemsDocument(planId, include) { + const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean)); + const data = []; + const included = []; + ITEMS.forEach((it, index) => { + const relationships = {}; + if (it.song) { + relationships.song = { data: { type: 'Song', id: it.song.id } }; + if (wants.has('song')) { + included.push({ type: 'Song', id: it.song.id, attributes: { title: it.song.title, author: it.song.author, ccli_number: it.song.ccli ? Number(it.song.ccli) : null } }); + } + if (it.arr) { + const arrId = `${it.song.id}-arr`; + relationships.arrangement = { data: { type: 'Arrangement', id: arrId } }; + if (wants.has('arrangement')) included.push({ type: 'Arrangement', id: arrId, attributes: { name: it.arr } }); + } + } + const noteRefs = (it.notes || []).map((n, i) => ({ type: 'ItemNote', id: `${it.id}-n${i}` })); + relationships.item_notes = { data: noteRefs }; + if (wants.has('item_notes')) { + (it.notes || []).forEach(([category, content], i) => { + included.push({ type: 'ItemNote', id: `${it.id}-n${i}`, attributes: { category_name: category, content } }); + }); + } + data.push({ + type: 'Item', + id: `${planId}-${it.id}`, + attributes: { + title: it.title, + sequence: index + 1, + item_type: it.type, + length: it.length, + service_position: it.pos, + description: it.desc || '', + html_details: '', + key_name: it.key || null, + }, + relationships, + }); + }); + return { data, included, meta: { total_count: data.length, count: data.length } }; +} + +/* ── JSON:API plumbing ──────────────────────────────────────────────── */ + +function folderRecord(f) { + return { + type: 'Folder', + id: f.id, + attributes: { name: f.name, container: f.parentId ? 'Folder' : 'Organization' }, + relationships: { parent: { data: f.parentId ? { type: 'Folder', id: f.parentId } : null } }, + }; +} + +function serviceTypeRecord(st) { + return { + type: 'ServiceType', + id: st.id, + attributes: { name: st.name, frequency: st.frequency, sequence: Number(st.id) }, + relationships: { parent: { data: st.parentId ? { type: 'Folder', id: st.parentId } : null } }, + }; +} + +function planTimeRecord(t) { + return { + type: 'PlanTime', + id: t.id, + attributes: { name: t.name, time_type: t.type, starts_at: iso(t.start), ends_at: iso(t.end), live_starts_at: null, live_ends_at: null }, + }; +} + +function send(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/vnd.api+json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); +} + +function collection(res, records, url) { + send(res, 200, { + links: { self: url.pathname + url.search }, + data: records, + included: [], + meta: { total_count: records.length, count: records.length }, + }); +} + +function notFound(res) { + send(res, 404, { errors: [{ code: 'not_found', title: 'Not Found', status: '404', detail: 'The resource you requested could not be found' }] }); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://127.0.0.1:${PORT}`); + console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`); + + if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) { + return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] }); + } + + const parts = url.pathname.replace(/^\/services\/v2\/?/, '').split('/').filter(Boolean); + if (!url.pathname.startsWith('/services/v2')) return notFound(res); + + // /folders[/{id}] + if (parts[0] === 'folders') { + if (parts.length === 1) return collection(res, FOLDERS.map(folderRecord), url); + const f = FOLDERS.find((x) => x.id === parts[1]); + if (!f) return notFound(res); + if (parts.length === 2) return send(res, 200, { data: folderRecord(f) }); + if (parts[2] === 'folders') return collection(res, FOLDERS.filter((x) => x.parentId === f.id).map(folderRecord), url); + if (parts[2] === 'service_types') return collection(res, SERVICE_TYPES.filter((x) => x.parentId === f.id).map(serviceTypeRecord), url); + return notFound(res); + } + + // /service_types[/{id}[/plans[/{pid}[/plan_times|items]]]] + if (parts[0] === 'service_types') { + if (parts.length === 1) return collection(res, SERVICE_TYPES.map(serviceTypeRecord), url); + const st = SERVICE_TYPES.find((x) => x.id === parts[1]); + if (!st) return notFound(res); + if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) }); + if (parts[2] !== 'plans') return notFound(res); + const plans = plansFor(st.id); + if (parts.length === 3) { + const filter = url.searchParams.get('filter') || ''; + const now = Date.now(); + let list = plans.slice(); + if (filter === 'future') list = list.filter((p) => p.sortDate >= new Date(now).setHours(0, 0, 0, 0)); + else if (filter === 'past') list = list.filter((p) => p.sortDate < now); + const order = url.searchParams.get('order') || 'sort_date'; + list.sort((a, b) => (order.startsWith('-') ? b.sortDate - a.sortDate : a.sortDate - b.sortDate)); + const perPage = Math.max(1, Math.min(100, Number(url.searchParams.get('per_page')) || 25)); + return collection(res, list.slice(0, perPage).map(planRecord), url); + } + const plan = plans.find((p) => p.id === parts[3]); + if (!plan) return notFound(res); + if (parts.length === 4) return send(res, 200, { data: planRecord(plan) }); + if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url); + if (parts[4] === 'items') { + const doc = itemsDocument(plan.id, url.searchParams.get('include')); + doc.links = { self: url.pathname + url.search }; + return send(res, 200, doc); + } + return notFound(res); + } + + notFound(res); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`[pco-mock] Planning Center mock on http://127.0.0.1:${PORT}/services/v2`); + console.log(`[pco-mock] today's plan "${plansFor('1001')[1].title}" — first service starts at ${new Date(firstServiceStart).toLocaleTimeString()}`); + console.log('[pco-mock] run ProdDash with: PCO_PLAN_API_BASE=http://127.0.0.1:%d node server.js', PORT); +}); From 50caf0202331d7761ba287adc867e9a318e7985c Mon Sep 17 00:00:00 2001 From: Waters Church Date: Wed, 9 Sep 2026 15:45:55 -0400 Subject: [PATCH 2/8] pco-plan: learn the shell's port from requests instead of config/proddash.json Main now keeps a per-machine data directory whose proddash.json can override the port, so reading the repo's config file was no longer a safe way to find the ProPresenter module's loopback feed. Guess the port the way the shell does (PORT, data-dir proddash.json, repo proddash.json, 24500), then confirm it from the Host header of the first tile request; a learned port that refuses the connection (a reverse proxy) is blacklisted and the guess kept. Co-Authored-By: Claude Fable 5.1 --- modules/pco-plan/server.js | 76 +++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js index 8871acd..b6a13dd 100644 --- a/modules/pco-plan/server.js +++ b/modules/pco-plan/server.js @@ -26,7 +26,9 @@ * second time, this module reads the propresenter-now-next module's own SSE * feed over loopback (/api/modules/propresenter-now-next/stream) and matches * the live presentation name to a plan item. Item start/end timestamps are - * tracked here, once, so every tile agrees on the runtime. + * tracked here, once, so every tile agrees on the runtime. The shell's port + * is guessed the way the shell decides it and then confirmed from the Host + * header of the first tile request. * * PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at * tools/pco-mock.js for development. @@ -34,6 +36,7 @@ const http = require('http'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const DEFAULT_API_BASE = 'https://api.planningcenteronline.com'; @@ -91,17 +94,42 @@ function clearedLive() { }; } -/** The shell's port, so the loopback Now/Next feed lands on this ProdDash. */ -function shellPort() { +/** + * First guess at the shell's port, so the loopback Now/Next feed lands on this + * ProdDash before any browser has talked to us. Mirrors how the shell decides: + * PORT env, then this machine's proddash.json in the data directory, then the + * checked-in config/proddash.json, then 24500. The guess is corrected from the + * Host header of the first request that reaches a route (see learnPort). + */ +function guessShellPort() { const env = Number(process.env.PORT); if (env) return env; - try { - const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config', 'proddash.json'), 'utf8')); - if (Number(cfg.port)) return Number(cfg.port); - } catch { /* fall through */ } + const home = os.homedir(); + const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim(); + const root = path.join(__dirname, '..', '..'); + const dataDir = fromEnv + ? path.resolve(root, fromEnv) + : process.platform === 'win32' + ? path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash') + : process.platform === 'darwin' + ? path.join(home, 'Library', 'Application Support', 'ProdDash') + : path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash'); + for (const file of [path.join(dataDir, 'proddash.json'), path.join(root, 'config', 'proddash.json')]) { + try { + const port = Number(JSON.parse(fs.readFileSync(file, 'utf8')).port); + if (port) return port; + } catch { /* try the next one */ } + } return 24500; } +/** Port a browser used to reach us, from a Host header ("10.3.8.41:24500"). */ +function portFromHost(hostHeader) { + const m = /:(\d+)$/.exec(String(hostHeader || '').trim()); + if (m) return Number(m[1]) || 0; + return hostHeader ? 80 : 0; +} + function localYmd(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); @@ -500,6 +528,23 @@ module.exports = { let nnRequest = null; let nnRetryTimer = null; let lastLiveName = null; + const guessedPort = guessShellPort(); + let nnPort = guessedPort; + /** Learned ports that refused the loopback connection (a reverse proxy's port, say). */ + const badPorts = new Set(); + + /** A browser reached us on this Host — normally the port the shell really serves on. */ + function learnPort(hostHeader) { + const port = portFromHost(hostHeader); + if (!port || port === nnPort || badPorts.has(port)) return; + log(`ProdDash is on port ${port} (guessed ${nnPort}) — reconnecting the ProPresenter feed there`); + nnPort = port; + if (!follow || stopped) return; + clearTimeout(nnRetryTimer); + try { nnRequest?.destroy(); } catch { /* gone */ } + nnRequest = null; + connectNowNext(); + } function resetLiveTracking() { const keep = state.live; @@ -593,7 +638,7 @@ module.exports = { function connectNowNext() { if (stopped || !follow) return; - const port = shellPort(); + const port = nnPort; const req = http.get( { host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } }, (res) => { @@ -628,7 +673,17 @@ module.exports = { res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS)); } ); - req.on('error', (err) => nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS)); + req.on('error', (err) => { + if (req !== nnRequest) return; // superseded by a reconnect on the learned port + if (port !== guessedPort) { + // The Host header lied (reverse proxy, port forward) — that port is + // not this ProdDash. Go back to the guess and never trust it again. + badPorts.add(port); + nnPort = guessedPort; + log(`port ${port} refused the loopback (${err.message}) — back to port ${guessedPort}`); + } + nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS); + }); req.setTimeout(45000, () => req.destroy(new Error('feed went silent'))); nnRequest = req; } @@ -665,6 +720,7 @@ module.exports = { streams, pco, getState: () => state, + learnPort, refresh() { clearTimeout(pollTimer); return poll(); @@ -707,10 +763,12 @@ module.exports = { return { 'GET /state': (req, res) => { + current?.learnPort(req.headers.host); json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() }); }, 'GET /stream': (req, res) => { + current?.learnPort(req.headers.host); res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', From 106ce0c1ce34e02c5a5aaac5b238b789996f270f Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 10 Sep 2026 15:31:12 -0400 Subject: [PATCH 3/8] Admin: collapsible / multi-column config groups, help text, grouped and dependent selects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the timers lane's admin schema additions unchanged (field `group`, `showWhen`, type `switch`, `optionsRoute`) so the two branches merge cleanly, and extends them for modules whose settings are richer than a handful of fields: - manifest `configGroups[name]` = { collapsed: true | false | "whenSet", columns: 2–4, help } — a credentials block that folds once it holds a saved secret, a show/hide checklist laid out as a compact grid - `help` on any field: one line of guidance under the control - select options may carry `group` → rendered as s (a folder tree browsable inside the select) - `optionsDependsOn: ""` refetches a select's options with that field's current value as a query parameter, now and on every change - a save re-runs the card's dynamic option fetches, so a list that needed the just-saved credentials fills in without a page reload; a blank "— choose —" is no longer added when the module offers its own blank option Documented in docs/MODULE-GUIDE.md. Co-Authored-By: Claude Fable 5.1 --- docs/MODULE-GUIDE.md | 64 ++++++- modules/pco-plan/setup.html | 372 ------------------------------------ public/admin/admin.css | 77 ++++++++ public/admin/admin.js | 207 ++++++++++++++++++-- server.js | 3 +- 5 files changed, 332 insertions(+), 391 deletions(-) delete mode 100644 modules/pco-plan/setup.html diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 674c231..c2da220 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -64,13 +64,67 @@ shell code changes, ever. Both schemas map field names to specs: ``` -{ "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint", +{ "type": "string" | "number" | "boolean" | "switch" | "select" | "password" | "endpoint", "label": "Shown next to the field", "default": , - "options": [ { "value": "a", "label": "A" }, … ] // "select" only + "group": "Section heading", // optional; see below + "showWhen": "", // optional; see below + "help": "One line shown under the control", // optional, admin config only + "options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only + "optionsRoute": "/devices", // "select" only, admin config only + "optionsDependsOn": "" // with optionsRoute; see below } ``` +`boolean` renders as a checkbox; `switch` is the same true/false value shown +as a toggle (use it for a feature on/off that other fields depend on). + +`group` gathers consecutive fields under a labeled subsection in the admin +page, so a module with several distinct concerns (e.g. a ProPresenter +connection and an LTC audio input) reads as clearly separated blocks. Fields +with no `group` render loose, before/after the grouped ones in schema order. + +A manifest-level `configGroups` map can dress a group up further, keyed by +the group name: + +```json +"configGroups": { + "Planning Center credentials": { "collapsed": "whenSet", "help": "Where to get a token…" }, + "Show / hide": { "collapsed": true, "columns": 3 } +} +``` + +- `collapsed`: `true` (starts folded), `false` (starts open but can fold), + or `"whenSet"` — folded once the group already holds values (a saved + password, or anything other than the schema default), open while it is + still empty. Omit it and the group is a plain, always-open block. +- `columns`: 2–4 lays the group's fields out in a grid — the way to keep a + long checklist of booleans compact. +- `help`: a line of guidance at the top of the group. + +`help` on a field puts one line of guidance under its control — allowed +placeholders, where a value comes from. Keep labels short and put the +explanation there. + +`showWhen: ""` hides a field until the boolean/`switch` field named +`` is on — e.g. the audio device and channel appear only once the LTC +listener switch is enabled. The hidden field still keeps its stored value. + +A `select` whose choices are only knowable at runtime (audio devices, serial +ports, discovered sources) sets `optionsRoute` instead of — or as a fallback +refresh of — static `options`: the admin page GETs +`/api/modules/` and expects `{ "options": [ { "value", +"label" }, … ] }` from one of the module's own server routes. The saved value +stays selectable even when the route is down or the option has vanished. (See +`propresenter-timers`' "Audio device" for a worked example.) + +An option may carry a `group`: options sharing one render under a single +``, so a tree (Planning Center folders, device classes) is +browsable inside the select. `optionsDependsOn: ""` makes the admin +page fetch the options with that other field's current value appended as a +query parameter (`?=`), now and again whenever it changes — e.g. +a plan list that follows the chosen service type. (Both in `pco-plan`.) + `password` fields are admin-config only in practice: their values are stored in the server's `modules.json` (in the per-machine data directory, see the README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin @@ -468,9 +522,9 @@ client-side failures (a tile that fails to start says so in the tile). Center is a cloud API, so the module reads its base URL from the environment instead of admin config: run ProdDash with `PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js`, enter any - Application ID + Secret in `/admin`, and pick "Sunday Service" in - `/modules/pco-plan/setup.html`. Its item titles match `pp-mock.js`, so - running both mocks exercises the ProPresenter matching. + Application ID + Secret under PCO Plan in `/admin`, save, then pick + "Sunday Service" from the Service type list. Its item titles match + `pp-mock.js`, so running both mocks exercises the ProPresenter matching. **The resilience checklist** — every module must pass this before it's done: diff --git a/modules/pco-plan/setup.html b/modules/pco-plan/setup.html deleted file mode 100644 index 9593838..0000000 --- a/modules/pco-plan/setup.html +++ /dev/null @@ -1,372 +0,0 @@ - - - - - -PCO Plan — Planning Center setup - - - - -
-
-

PCO Plan · Planning Center setup

- ← Admin - Dashboard -
-

Browse your Planning Center Services folders and choose the service type whose plans the PCO Plan tiles should follow.

- -
-

Currently following

-
Loading…
-
-
- -
-

Service types

-
-
Loading folders from Planning Center…
-
- -
Credentials (Application ID + Secret from api.planningcenteronline.com, or an OAuth token) are entered under PCO Plan in Admin. Saving here re-initialises the module; open tiles pick up the new plan on their own.
-
-
- - - - diff --git a/public/admin/admin.css b/public/admin/admin.css index 4a7c0e7..5487cd6 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -113,6 +113,83 @@ body { overflow: auto; } .endpoint-row .ep-sep { color: var(--muted); font-weight: 600; } .module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; } +/* labeled config subsections (manifest field `group`) */ +.field-group { + margin: 14px 0 4px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 9px; +} +.field-group-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 10px; +} +.field-group .field:last-child { margin-bottom: 0; } + +/* collapsible groups (manifest configGroups[name].collapsed) */ +details.field-group > summary.field-group-title { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 0; + user-select: none; +} +details.field-group > summary.field-group-title::-webkit-details-marker { display: none; } +details.field-group > summary.field-group-title::before { + content: ""; + width: 0; height: 0; + border-left: 5px solid var(--muted); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + transition: transform 0.15s; +} +details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); } +details.field-group[open] > summary.field-group-title { margin-bottom: 10px; } +details.field-group > summary.field-group-title:hover { color: var(--text); } + +/* multi-column groups (configGroups[name].columns): a neat checklist grid */ +.field-group-body.is-columns { + display: grid; + grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); + gap: 4px 16px; +} +.field-group-body.is-columns .field { margin-bottom: 4px; max-width: none; } +.field-group-body.is-columns .field-group-help { grid-column: 1 / -1; } +@media (max-width: 560px) { .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } } + +.field-group-help, +.field-help { + display: block; + font-size: 11.5px; + line-height: 1.45; + color: var(--muted); +} +.field-group-help { margin: -4px 0 8px; } +.field-help { margin-top: 2px; } +.field-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + +/* showWhen: a hidden dependent field must actually collapse — `.field`'s + own `display:flex` (an author rule) otherwise outranks the [hidden] UA + default and the field stays visible. */ +.module-form .field[hidden] { display: none; } + +/* a boolean rendered as a toggle (manifest type `switch`) */ +.field.switch-field { + flex-direction: row; + align-items: center; + gap: 10px; + color: var(--text); + cursor: pointer; + max-width: none; +} +.field.switch-field .switch-label { font-size: 13px; } + .module-actions { display: flex; align-items: center; diff --git a/public/admin/admin.js b/public/admin/admin.js index 9f43bbc..891c28b 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -83,19 +83,43 @@ function healthText(mod) { return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).'; } -function fieldFor(key, spec, value, passwordSet) { +function fieldFor(key, spec, value, passwordSet, moduleId) { const type = spec?.type || 'string'; const label = spec?.label || key; const field = document.createElement('label'); - field.className = 'field' + (type === 'boolean' ? ' check' : ''); + field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : ''); let read; + // For boolean/switch fields, the control other fields may depend on + // (showWhen) — returned so the caller can wire visibility. + let control = null; + // For selects: the element (optionsDependsOn sources) and, with an + // optionsRoute, a function that (re)loads the options. + let selectEl = null; + let refetch = null; - if (type === 'boolean') { + if (type === 'switch') { + // Same toggle as the module enable switch, inline with its label. + const toggle = document.createElement('span'); + toggle.className = 'switch'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = Boolean(value); + const track = document.createElement('span'); + track.className = 'track'; + toggle.append(input, track); + const text = document.createElement('span'); + text.className = 'switch-label'; + text.textContent = label; + field.append(toggle, text); + read = () => input.checked; + control = input; + } else if (type === 'boolean') { const input = document.createElement('input'); input.type = 'checkbox'; input.checked = Boolean(value); field.append(input, document.createTextNode(label)); read = () => input.checked; + control = input; } else if (type === 'endpoint') { // One consistent "Host / IP : Port" control for every module's upstream. const span = document.createElement('span'); @@ -122,19 +146,69 @@ function fieldFor(key, spec, value, passwordSet) { row.append(host, sep, port); field.append(span, row); read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 }); - } else if (type === 'select' && Array.isArray(spec.options)) { + } else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) { const span = document.createElement('span'); span.textContent = label; const select = document.createElement('select'); - for (const opt of spec.options) { - const o = document.createElement('option'); - o.value = String(typeof opt === 'object' ? opt.value : opt); - o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); - select.appendChild(o); + const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt); + const fill = (options) => { + // Keep what the admin has picked but not yet saved across a refill + // (a dependent select refetching), else the saved value. + const saved = String(value ?? ''); + const wanted = select.options.length ? select.value : saved; + select.innerHTML = ''; + // The saved value must stay selectable even when it isn't among the + // live options (device unplugged, module route down). + if (saved && !options.some((opt) => optValue(opt) === saved)) { + options = [{ value: saved, label: `${saved} (saved)` }, ...options]; + } + // Nothing saved yet → a blank "choose" entry, unless the module already + // offers a blank option of its own (an "Automatic" default, say). + if (!saved && !options.some((opt) => optValue(opt) === '')) { + options = [{ value: '', label: '— choose —' }, ...options]; + } + // Options may carry a `group` (a folder path, a device class): options + // sharing one render under a single , in first-seen order. + const groups = new Map(); + for (const opt of options) { + const o = document.createElement('option'); + o.value = optValue(opt); + o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); + const g = typeof opt === 'object' && opt.group ? String(opt.group) : ''; + if (!g) { + select.appendChild(o); + continue; + } + if (!groups.has(g)) { + const og = document.createElement('optgroup'); + og.label = g; + groups.set(g, og); + select.appendChild(og); + } + groups.get(g).appendChild(o); + } + select.value = options.some((opt) => optValue(opt) === wanted) ? wanted : saved; + }; + fill(Array.isArray(spec.options) ? spec.options : []); + if (spec.optionsRoute && moduleId) { + // Options discovered at runtime (audio devices, ports, sources…): + // the module serves them from one of its own routes. With + // optionsDependsOn the caller triggers the fetch (and re-fetches when + // that other field changes), passing its value as a query parameter. + refetch = (query) => { + const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : ''; + return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`) + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + if (Array.isArray(body?.options)) fill(body.options); + }) + .catch(() => { /* keep the static/saved options */ }); + }; + if (!spec.optionsDependsOn) refetch(); } - select.value = String(value ?? ''); field.append(span, select); read = () => select.value; + selectEl = select; } else { const span = document.createElement('span'); span.textContent = label; @@ -149,7 +223,32 @@ function fieldFor(key, spec, value, passwordSet) { field.append(span, input); read = () => (type === 'number' ? Number(input.value) : input.value); } - return { field, read }; + if (spec?.help) { + // One line of guidance under the control (allowed placeholders, where a + // value comes from…) — the label stays short. + const help = document.createElement('small'); + help.className = 'field-help'; + help.textContent = spec.help; + field.appendChild(help); + } + return { field, read, control, selectEl, refetch }; +} + +/** + * Does a config group have anything entered? Drives `collapsed: "whenSet"`: + * a credentials block stays folded once it holds a saved secret or a value + * other than the schema default, and opens by itself while still empty. + */ +function groupHasValues(fields, mod) { + return fields.some(({ key, spec }) => { + const type = spec?.type || 'string'; + if (type === 'password') return Boolean(mod.passwordSet[key]); + if (type === 'boolean' || type === 'switch') return false; + const v = mod.config[key]; + if (type === 'endpoint') return Boolean(v?.host); + if (v === undefined || v === null || String(v) === '') return false; + return !('default' in (spec || {})) || String(v) !== String(spec.default); + }); } function buildModuleCard(mod) { @@ -226,10 +325,88 @@ function buildModuleCard(mod) { const form = document.createElement('div'); form.className = 'module-form'; const readers = new Map(); + const controls = new Map(); // key → boolean/switch input (for showWhen) + const selects = new Map(); // key → select element (optionsDependsOn sources) + const dependents = []; // { field, when } to show/hide by a control's state + const optionDeps = []; // { refetch, on } selects whose options follow another field + const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list) + const collapsibles = []; // { el, meta, fields } groups that fold + // Fields render in schema order; a `group` starts a labeled subsection + // and consecutive same-group fields share it. The manifest's optional + // `configGroups[name]` adds { collapsed, columns, help } for that group. + const groupMeta = mod.configGroups && typeof mod.configGroups === 'object' ? mod.configGroups : {}; + let groupName = null; + let container = form; + let currentGroup = null; for (const [key, spec] of schemaKeys) { - const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key]); + const g = spec?.group || ''; + if (g !== groupName) { + groupName = g; + currentGroup = null; + if (g) { + const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {}; + const folds = meta.collapsed !== undefined; + const group = document.createElement(folds ? 'details' : 'div'); + group.className = 'field-group'; + const title = document.createElement(folds ? 'summary' : 'div'); + title.className = 'field-group-title'; + title.textContent = g; + group.appendChild(title); + const body = document.createElement('div'); + body.className = 'field-group-body'; + const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0); + if (cols > 1) { + body.classList.add('is-columns'); + body.style.setProperty('--cols', String(cols)); + } + if (meta.help) { + const help = document.createElement('div'); + help.className = 'field-group-help'; + help.textContent = meta.help; + body.appendChild(help); + } + group.appendChild(body); + form.appendChild(group); + container = body; + currentGroup = { el: group, meta, fields: [] }; + if (folds) collapsibles.push(currentGroup); + } else { + container = form; + } + } + const { field, read, control, selectEl, refetch } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id); readers.set(key, read); - form.appendChild(field); + if (control) controls.set(key, control); + if (selectEl) selects.set(key, selectEl); + if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn }); + else if (refetch) reloaders.push(() => refetch()); + if (spec?.showWhen) dependents.push({ field, when: spec.showWhen }); + if (currentGroup) currentGroup.fields.push({ key, spec }); + container.appendChild(field); + } + // A field with showWhen:"K" is visible only while K's toggle is on. + for (const dep of dependents) { + const ctl = controls.get(dep.when); + if (!ctl) continue; + const sync = () => { dep.field.hidden = !ctl.checked; }; + ctl.addEventListener('change', sync); + sync(); + } + // A select with optionsDependsOn:"K" loads its options with K's current + // value as a query parameter, now and whenever K changes (a plan list + // that follows the chosen service type). + for (const dep of optionDeps) { + const src = selects.get(dep.on) || controls.get(dep.on); + const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) }); + if (src) src.addEventListener('change', run); + reloaders.push(run); + run(); + } + // Collapsible groups: `collapsed: true` starts folded, `false` open, + // "whenSet" folded only once the group already holds values. + for (const grp of collapsibles) { + const c = grp.meta.collapsed; + grp.el.open = !(c === true || (c === 'whenSet' && groupHasValues(grp.fields, mod))); } const actions = document.createElement('div'); actions.className = 'module-actions'; @@ -256,6 +433,10 @@ function buildModuleCard(mod) { status.textContent = 'Saved — module re-initialised. Open tiles reconnect on their own.'; status.className = 'admin-status ok'; refreshStatus(); + // What the module can list may have changed (credentials just + // saved → the upstream is reachable now): reload the dynamic selects. + // The re-init is quick but not instant; give it a beat. + setTimeout(() => { for (const reload of reloaders) reload(); }, 800); } catch (e) { status.textContent = e.message; status.className = 'admin-status error'; diff --git a/server.js b/server.js index 98be267..548729d 100644 --- a/server.js +++ b/server.js @@ -485,6 +485,7 @@ function adminModuleView(id) { hasServer: Boolean(man.server), enabled: isEnabled(id), configSchema: man.configSchema || {}, + configGroups: man.configGroups && typeof man.configGroups === 'object' ? man.configGroups : {}, config: values, passwordSet, mountError: entry?.error || '', @@ -505,7 +506,7 @@ function applyConfigPatch(id, patch) { value = current[key] || ''; } if (type === 'number') value = Number(value) || 0; - else if (type === 'boolean') value = Boolean(value); + else if (type === 'boolean' || type === 'switch') value = Boolean(value); else if (type === 'endpoint') { const src = value && typeof value === 'object' ? value : {}; value = { From 20dc2deb2bf4c341d8b753eddd6c07584dfc1c65 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 10 Sep 2026 15:31:12 -0400 Subject: [PATCH 4/8] 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'), ]; } From 01eac1bdc480683296f067b1f62aba17e0b9e720 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 10 Sep 2026 16:20:45 -0400 Subject: [PATCH 5/8] Shell: grouped, collapsible, multi-column per-tile settings popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gear popover learns the admin form's conventions: an instanceSchema field's `group` starts a labeled subsection, `help` puts a line of guidance under a control, and a manifest `instanceGroups[name]` adds { collapsed, columns, help } for that group (rendered as
with a grid body; container queries drop to two or one column in narrow tiles). /api/modules now exposes instanceGroups. Lets a module keep a long show/hide checklist per tile — where display choices belong — without the popover turning into a scroll of thirty loose checkboxes. Co-Authored-By: Claude Fable 5.1 --- docs/MODULE-GUIDE.md | 6 +++++ public/shell.js | 51 ++++++++++++++++++++++++++++++++++- public/style.css | 64 ++++++++++++++++++++++++++++++++++++++++++++ server.js | 1 + 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index c2da220..d6cd507 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -106,6 +106,12 @@ the group name: placeholders, where a value comes from. Keep labels short and put the explanation there. +`group` and `help` work on `instanceSchema` fields too, and a manifest +`instanceGroups` map plays the `configGroups` role for the tile's gear +popover (`collapsed: true | false`, `columns`, `help`). That is how a module +with a long show/hide checklist keeps the popover usable — see `pco-plan`. +Display choices belong there, per tile; admin config is for connections. + `showWhen: ""` hides a field until the boolean/`switch` field named `` is on — e.g. the audio device and channel appear only once the LTC listener switch is enabled. The hidden field still keeps its stored value. diff --git a/public/shell.js b/public/shell.js index 3936ede..0ae98f3 100644 --- a/public/shell.js +++ b/public/shell.js @@ -934,11 +934,54 @@ function toggleSettingsPopover(tile, el) { pop.className = 'tile-settings'; const inputs = new Map(); + // Fields render in schema order; a field's `group` starts a labeled + // subsection that consecutive same-group fields share, and the manifest's + // optional `instanceGroups[name]` adds { collapsed, columns, help } — + // the same conventions as the admin page's config form. + const groupMeta = man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {}; + let groupName = null; + let container = pop; + for (const [key, spec] of Object.entries(man.instanceSchema)) { const type = spec?.type || 'string'; const label = spec?.label || key; const current = key in (tile.settings || {}) ? tile.settings[key] : spec?.default; + const g = spec?.group || ''; + if (g !== groupName) { + groupName = g; + if (g) { + const meta = groupMeta[g] && typeof groupMeta[g] === 'object' ? groupMeta[g] : {}; + const folds = meta.collapsed !== undefined; + const group = document.createElement(folds ? 'details' : 'div'); + group.className = 'field-group'; + if (folds) group.open = !meta.collapsed; + const title = document.createElement(folds ? 'summary' : 'div'); + title.className = 'field-group-title'; + title.textContent = g; + group.appendChild(title); + const body = document.createElement('div'); + body.className = 'field-group-body'; + const cols = Math.min(4, Math.trunc(Number(meta.columns)) || 0); + if (cols > 1) { + body.classList.add('is-columns'); + body.style.setProperty('--cols', String(cols)); + } + if (meta.help) { + const help = document.createElement('div'); + help.className = 'field-group-help'; + help.textContent = meta.help; + body.appendChild(help); + } + group.appendChild(body); + pop.appendChild(group); + pop.classList.add('has-groups'); + container = body; + } else { + container = pop; + } + } + const field = document.createElement('label'); field.className = 'field' + (type === 'boolean' ? ' check' : ''); @@ -970,7 +1013,13 @@ function toggleSettingsPopover(tile, el) { field.append(span, input); inputs.set(key, () => (type === 'number' ? Number(input.value) : input.value)); } - pop.appendChild(field); + if (spec?.help) { + const help = document.createElement('small'); + help.className = 'field-help'; + help.textContent = spec.help; + field.appendChild(help); + } + container.appendChild(field); } const actions = document.createElement('div'); diff --git a/public/style.css b/public/style.css index 77a5b71..e5f52b9 100644 --- a/public/style.css +++ b/public/style.css @@ -529,6 +529,70 @@ body.fullscreen #grid { padding-bottom: 0; } box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); } +/* A popover with grouped settings needs room for its columns; it still + never grows past the tile (max-width above). */ +.tile-settings.has-groups { + min-width: min(540px, calc(100% - 12px)); + container-type: inline-size; +} +.tile-settings .field-group { + margin: 0 0 10px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; +} +.tile-settings .field-group-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 8px; +} +.tile-settings details.field-group > summary.field-group-title { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 0; + user-select: none; +} +.tile-settings details.field-group > summary::-webkit-details-marker { display: none; } +.tile-settings details.field-group > summary.field-group-title::before { + content: ""; + width: 0; height: 0; + border-left: 5px solid var(--muted); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + transition: transform 0.15s; +} +.tile-settings details.field-group[open] > summary.field-group-title::before { transform: rotate(90deg); } +.tile-settings details.field-group[open] > summary.field-group-title { margin-bottom: 8px; } +.tile-settings .field-group .field:last-child { margin-bottom: 0; } +.tile-settings .field-group-body.is-columns { + display: grid; + grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); + gap: 3px 14px; +} +.tile-settings .field-group-body.is-columns .field { margin-bottom: 3px; } +.tile-settings .field-group-body.is-columns .field-group-help { grid-column: 1 / -1; } +@container (max-width: 420px) { + .tile-settings .field-group-body.is-columns { grid-template-columns: 1fr 1fr; } +} +@container (max-width: 260px) { + .tile-settings .field-group-body.is-columns { grid-template-columns: 1fr; } +} +.tile-settings .field-group-help, +.tile-settings .field-help { + display: block; + font-size: 11px; + line-height: 1.4; + color: var(--muted); +} +.tile-settings .field-group-help { margin: -2px 0 6px; } +.tile-settings .field-help { margin-top: 1px; } + .field { display: flex; flex-direction: column; diff --git a/server.js b/server.js index 548729d..60bcc1b 100644 --- a/server.js +++ b/server.js @@ -402,6 +402,7 @@ async function clientManifest(id) { minSize: man.minSize || { w: 1, h: 1 }, defaultSize: man.defaultSize || { w: 4, h: 3 }, instanceSchema: man.instanceSchema || {}, + instanceGroups: man.instanceGroups && typeof man.instanceGroups === 'object' ? man.instanceGroups : {}, hasServer: Boolean(man.server), config: clientConfig(id), tiles: await moduleTiles(id), From 4c5d7956abd804541817d33c03e9f7a0c6388290 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 10 Sep 2026 16:20:46 -0400 Subject: [PATCH 6/8] pco-plan: display settings are per tile again (gear menu), admin keeps the connection Top bar (template, item-type tint, key color) and the whole show/hide checklist move from admin config to instanceSchema, grouped in the gear popover ("Top bar" open, "Show / hide" folded, three columns). They are browser-side choices stored with the layout, not server state. Admin now holds only credentials, service type, plan, poll interval and the ProPresenter follow switch. noteCategories moves to the tile too. Co-Authored-By: Claude Fable 5.1 --- README.md | 13 +++++++------ modules/pco-plan/client.js | 20 +++++++++----------- modules/pco-plan/module.json | 25 +++++++++++++++---------- 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 2e7cf07..da2bf4c 100644 --- a/README.md +++ b/README.md @@ -140,12 +140,13 @@ ignored from then on. 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. + ahead/behind the service is. Admin config is the connection: Planning + Center credentials (Personal Access Token app ID + secret, or an OAuth + token), the service type (a list grouped by your Planning Center folders) + and an optional pinned plan. Everything about the display is per tile + (gear menu): the top-bar text (placeholders such as + `{series} • {part} | {serviceType} • {date}`), colors, text size, and a + show/hide checklist for every detail. - **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 a883ed4..7efd441 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -2,9 +2,9 @@ The server part reads Planning Center and (optionally) follows the ProPresenter module's live item; this tile only renders the plan it is - 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. */ + streamed. What is shown is this tile's own choice (gear menu → + moduleApi.instanceSettings: show/hide checklist, top-bar template, + colors, text size); /admin only holds the connection settings. */ const REFRESH_SVG = ''; @@ -43,10 +43,10 @@ export default function create({ root, moduleApi }) { const listEl = root.querySelector('.pp-list'); const footEl = root.querySelector('.pp-foot'); - /** Admin-wide display settings (the show/hide checklist lives there). */ - const cfg = () => moduleApi.config || {}; + /** This tile's display settings (gear menu) — read fresh on every render. */ + const cfg = () => moduleApi.instanceSettings || {}; const on = (key) => cfg()[key] !== false; // unset = shown - const tilePrefs = () => moduleApi.instanceSettings; + const tilePrefs = cfg; const serverNow = () => Date.now() + skew; /* ── title-bar controls ─────────────────────────────────────────── */ @@ -270,7 +270,7 @@ 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 the PCO Plan settings in Admin.' }; + if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' }; return null; } @@ -612,10 +612,8 @@ export default function create({ root, moduleApi }) { root.innerHTML = ''; }, onConfigChange() { - // Display settings changed in Admin: re-render from the new - // moduleApi.config. The server re-inits too and re-sends its state. - lastListKey = ''; - render(); + // Admin holds only connection settings; the server re-inits and its + // stream re-sends the state. Nothing cached here to refresh. }, }; } diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index 8e9c173..56dc60c 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -1,7 +1,7 @@ { "id": "pco-plan", "name": "PCO Plan", - "version": "1.1.0", + "version": "1.2.0", "description": "Order of service from Planning Center Services, following ProPresenter live", "client": "client.js", "server": "server.js", @@ -12,11 +12,6 @@ "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": { @@ -63,7 +58,21 @@ "type": "switch", "label": "Follow the ProPresenter module's live item to highlight the current plan item and time it", "default": true + } + }, + "instanceGroups": { + "Top bar": { + "collapsed": false }, + "Show / hide": { + "collapsed": true, + "columns": 3, + "help": "Untick anything this tile should not show." + } + }, + "instanceSchema": { + "textSize": { "type": "number", "label": "Text size (px)", "default": 14 }, + "autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true }, "noteCategories": { "type": "string", "label": "Item note categories to show (comma-separated, blank = all)", @@ -114,9 +123,5 @@ "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": { - "textSize": { "type": "number", "label": "Text size (px)", "default": 14 }, - "autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true } } } From a71b14d75502ba1f65b5ad86c25b0968cb70680b Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 10 Sep 2026 16:31:46 -0400 Subject: [PATCH 7/8] Shell: `color` field type (swatch picker) in tile settings and admin config Renders with the label beside it; the stored value is always a #rrggbb string (3-digit hex widened, anything else falls back to the schema default). Same styling in the gear popover and the admin form. Co-Authored-By: Claude Fable 5.1 --- docs/MODULE-GUIDE.md | 4 +++- public/admin/admin.js | 11 +++++++++++ public/shell.js | 18 ++++++++++++++++++ public/style.css | 21 +++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index d6cd507..ca7bdd0 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -64,7 +64,7 @@ shell code changes, ever. Both schemas map field names to specs: ``` -{ "type": "string" | "number" | "boolean" | "switch" | "select" | "password" | "endpoint", +{ "type": "string" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint", "label": "Shown next to the field", "default": , "group": "Section heading", // optional; see below @@ -78,6 +78,8 @@ Both schemas map field names to specs: `boolean` renders as a checkbox; `switch` is the same true/false value shown as a toggle (use it for a feature on/off that other fields depend on). +`color` renders a swatch picker and stores a `#rrggbb` string (give it a hex +default; the tile applies it as a CSS custom property — see `pco-plan`). `group` gathers consecutive fields under a labeled subsection in the admin page, so a module with several distinct concerns (e.g. a ProPresenter diff --git a/public/admin/admin.js b/public/admin/admin.js index 891c28b..151c4dd 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -209,6 +209,17 @@ function fieldFor(key, spec, value, passwordSet, moduleId) { field.append(span, select); read = () => select.value; selectEl = select; + } else if (type === 'color') { + // A swatch picker; the value is always a #rrggbb string. + field.classList.add('color-field'); + const span = document.createElement('span'); + span.textContent = label; + const input = document.createElement('input'); + input.type = 'color'; + const hex = (v, fb) => (/^#[0-9a-fA-F]{6}$/.test(String(v ?? '').trim()) ? String(v).trim().toLowerCase() : fb); + input.value = hex(value, hex(spec?.default, '#2ee59a')); + field.append(span, input); + read = () => input.value; } else { const span = document.createElement('span'); span.textContent = label; diff --git a/public/shell.js b/public/shell.js index 0ae98f3..5a8fc98 100644 --- a/public/shell.js +++ b/public/shell.js @@ -919,6 +919,14 @@ function unmountModule(tile) { /* ── per-tile settings popover ──────────────────────────────────────── */ +/** `` only accepts #rrggbb — anything else falls back. */ +function colorHex(value, fallback) { + const s = String(value ?? '').trim(); + if (/^#[0-9a-fA-F]{6}$/.test(s)) return s.toLowerCase(); + if (/^#[0-9a-fA-F]{3}$/.test(s)) return ('#' + s.slice(1).split('').map((c) => c + c).join('')).toLowerCase(); + return fallback; +} + function closeSettingsPopovers() { document.querySelectorAll('.tile-settings').forEach((p) => p.remove()); } @@ -1004,6 +1012,16 @@ function toggleSettingsPopover(tile, el) { select.value = String(current ?? ''); field.append(span, select); inputs.set(key, () => select.value); + } else if (type === 'color') { + // A swatch picker; the value is always a #rrggbb string. + field.classList.add('color-field'); + const span = document.createElement('span'); + span.textContent = label; + const input = document.createElement('input'); + input.type = 'color'; + input.value = colorHex(current, colorHex(spec?.default, '#2ee59a')); + field.append(span, input); + inputs.set(key, () => input.value); } else { const span = document.createElement('span'); span.textContent = label; diff --git a/public/style.css b/public/style.css index e5f52b9..f861d18 100644 --- a/public/style.css +++ b/public/style.css @@ -626,6 +626,27 @@ body.fullscreen #grid { padding-bottom: 0; } } .field.check input { width: 15px; height: 15px; accent-color: var(--accent); } +/* color fields: label on the left, swatch on the right */ +.field.color-field { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} +.field.color-field > span { flex: 1; min-width: 0; } +.field.color-field input[type="color"] { + width: 44px; + height: 26px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + cursor: pointer; +} +.field.color-field input[type="color"]:focus { outline: none; border-color: var(--accent); } +.field.color-field .field-help { flex-basis: 100%; } + /* ── modal dialogs (layout name prompt etc.) ───────────────────────── */ #modal-backdrop { From d567f5459a44718568fd6ff9ab9db5115027c0dd Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 10 Sep 2026 16:31:46 -0400 Subject: [PATCH 8/8] pco-plan: edge-to-edge headers, tile colors for headers / current item / key, gradient switch Section headers lose the bracket look (no accent bar, no rounded corners) and span the full tile width, with text still aligned to the other rows. A "Colors" group in the gear menu holds swatch pickers for the song key, section headers and the current item, a switch for fading the header color to the right (on by default), and the item-type tint. Colors apply as CSS custom properties on the tile. Co-Authored-By: Claude Fable 5.1 --- modules/pco-plan/client.js | 10 ++++++++-- modules/pco-plan/module.json | 22 +++++++++------------ modules/pco-plan/style.css | 37 +++++++++++++++++++++++------------- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index 7efd441..8e88a70 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -526,8 +526,14 @@ 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'); + wrap.classList.toggle('header-gradient', cfg().headerGradient !== false); + const color = (value, fallback) => { + const s = String(value || '').trim(); + return /^[#a-zA-Z0-9(),.%\s-]+$/.test(s) && s ? s : fallback; + }; + wrap.style.setProperty('--pp-key', color(cfg().keyColor, '#4ea1ff')); + wrap.style.setProperty('--pp-header', color(cfg().headerColor, '#2ee59a')); + wrap.style.setProperty('--pp-current', color(cfg().currentColor, '#2ee59a')); renderStatus(); if (!state) { headEl.hidden = true; diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index 56dc60c..38345ce 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -64,6 +64,10 @@ "Top bar": { "collapsed": false }, + "Colors": { + "collapsed": false, + "columns": 2 + }, "Show / hide": { "collapsed": true, "columns": 3, @@ -85,19 +89,11 @@ "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." - }, + "keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" }, + "headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" }, + "currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" }, + "headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" }, + "tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" }, "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" }, diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index c73c935..a808d56 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -5,6 +5,8 @@ [data-module="pco-plan"] .pp-wrap { --pp-size: 14px; --pp-key: #4ea1ff; + --pp-header: var(--accent); + --pp-current: var(--accent); position: absolute; inset: 0; display: flex; @@ -103,18 +105,27 @@ [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. */ +/* Section headers: a band that runs edge to edge across the tile (negative + margins cancel the list padding) so a new part of the service is + unmistakable — tile-chosen color, solid or fading to the right. The + left padding keeps the text aligned with the other rows' text. */ [data-module="pco-plan"] .pp-row.is-header { - margin-top: 0.9em; - padding: 0.5em 0.7em 0.45em; + margin: 0.9em -0.5em 0; + padding: 0.5em 1em 0.45em calc(1em + 3px); 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); + border-left: none; + border-radius: 0; + background: color-mix(in srgb, var(--pp-header) 22%, transparent); +} +[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0; } +[data-module="pco-plan"] .pp-row + .pp-row.is-header { margin-top: 0.9em; } +[data-module="pco-plan"] .pp-row.is-header:hover { background: color-mix(in srgb, var(--pp-header) 28%, transparent); } +[data-module="pco-plan"] .header-gradient .pp-row.is-header { + background: linear-gradient(90deg, color-mix(in srgb, var(--pp-header) 30%, transparent), color-mix(in srgb, var(--pp-header) 6%, transparent) 70%, transparent); +} +[data-module="pco-plan"] .header-gradient .pp-row.is-header:hover { + background: linear-gradient(90deg, color-mix(in srgb, var(--pp-header) 36%, transparent), color-mix(in srgb, var(--pp-header) 8%, transparent) 70%, transparent); } -[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.85em; font-weight: 800; @@ -133,8 +144,8 @@ [data-module="pco-plan"] .pp-row.is-done:hover { opacity: 0.8; } [data-module="pco-plan"] .pp-row.is-current { - border-left-color: var(--accent); - background: color-mix(in srgb, var(--accent) 16%, transparent); + border-left-color: var(--pp-current); + background: color-mix(in srgb, var(--pp-current) 16%, transparent); opacity: 1; } @@ -159,7 +170,7 @@ text-align: right; white-space: nowrap; } -[data-module="pco-plan"] .pp-row.is-current .pp-col-time { color: var(--accent); font-weight: 700; } +[data-module="pco-plan"] .pp-row.is-current .pp-col-time { color: var(--pp-current); font-weight: 700; } [data-module="pco-plan"] .pp-col-main { flex: 1; min-width: 0; } @@ -196,7 +207,7 @@ display: none; padding: 0.05em 0.45em; border-radius: 999px; - background: var(--accent); + background: var(--pp-current); color: var(--bg); font-size: 0.65em; font-weight: 800;