diff --git a/README.md b/README.md index f1fe9e9..d08a877 100644 --- a/README.md +++ b/README.md @@ -194,14 +194,28 @@ below. Nothing is installed without someone clicking. session (Start ProdDash.command) and approve the one-time prompt; a server started over SSH or by bare launchd silently captures zeros. (Engine self-test: `node modules/propresenter-timers/ltc.selftest.js`.) +- **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. 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 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. 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 a545320..cba9f14 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -99,24 +99,56 @@ the app); its settings are kept for a later reinstall. 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 "showWhen": "", // optional; see below - "options": [ { "value": "a", "label": "A" }, … ], // "select" only - "optionsRoute": "/devices" // "select" only, admin config only + "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). +`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 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. + +`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. @@ -129,6 +161,13 @@ refresh of — static `options`: the admin page GETs 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 @@ -522,6 +561,15 @@ client-side failures (a tile that fails to start says so in the tile). a timecode API). Also serves the stage-display websocket with an LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its failure paths). 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 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/client.js b/modules/pco-plan/client.js new file mode 100644 index 0000000..8e88a70 --- /dev/null +++ b/modules/pco-plan/client.js @@ -0,0 +1,625 @@ +/* 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. 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 = ''; + +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'); + + /** 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 = cfg; + 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(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) }); + + /* ── 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'; + 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 items = Array.isArray(state?.items) ? state.items : []; + return items.filter((it) => { + 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(cfg().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 — pick one in Admin'); + 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}`); + } + + /* ── 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 = on('showTopBar') && plan; + headEl.hidden = !show; + if (!show) return; + const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues()); + planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan'; + planSubEl.textContent = line1 ? line2 || '' : ''; + planSubEl.hidden = !planSubEl.textContent; + 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 (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 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))}`; + clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead'); + } + return; + } + if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) { + clockLabelEl.textContent = 'Starts in'; + clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000); + return; + } + if (on('showRunningClock') && 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: '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: '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) { + 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 (gear menu).' }; + return null; + } + + 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'; + + 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'; + 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 (!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 (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; + titleLine.appendChild(arr); + } + } + main.appendChild(titleLine); + + 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 (on('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 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); + } + 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 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(); + 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(); + } + + /** Current / done classes and the elapsed timers, without rebuilding rows. */ + function renderLiveMarks() { + const live = state?.live || {}; + const now = serverNow(); + const currentId = live.following ? live.currentItemId || '' : ''; + const history = live.history || {}; + const byId = new Map(state.items.map((it) => [it.id, it])); + // Everything before the current item counts as done, matched or not. + 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); + } + } + const showNow = on('showNowIndicator'); + for (const row of listEl.querySelectorAll('.pp-row')) { + const id = row.dataset.id; + const item = byId.get(id); + 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]; + 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'); + } + if (currentId !== lastCurrentId) { + lastCurrentId = currentId; + 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' }); + } + } + } + + /* ── footer: what ProPresenter is doing ─────────────────────────── */ + + function renderFoot() { + const live = state?.live; + const show = on('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); + wrap.classList.toggle('tint-types', Boolean(cfg().tintByType)); + 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; + renderList(); + footEl.hidden = true; + return; + } + renderHead(); + renderList(); + renderFoot(); + } + + function applyPrefs() { + const size = Math.min(40, Math.max(9, Number(tilePrefs().textSize) || 14)); + wrap.style.setProperty('--pp-size', `${size}px`); + lastListKey = ''; + render(); + } + + function tick() { + if (!state?.plan) return; + renderClock(); + if (state.live?.following && on('showItemRuntime')) 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() { + // 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 new file mode 100644 index 0000000..25537dd --- /dev/null +++ b/modules/pco-plan/module.json @@ -0,0 +1,124 @@ +{ + "id": "pco-plan", + "name": "PCO Plan", + "version": "1.3.0", + "proddash": ">=1.3.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." + } + }, + "configSchema": { + "appId": { + "type": "string", + "label": "Application ID", + "default": "", + "group": "Planning Center credentials" + }, + "secret": { + "type": "password", + "label": "Secret", + "default": "", + "group": "Planning Center credentials" + }, + "accessToken": { + "type": "password", + "label": "OAuth 2.0 access token (instead of Application ID + Secret)", + "default": "", + "group": "Planning Center credentials" + }, + "serviceTypeId": { + "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": "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", + "label": "Re-read the plan every N seconds", + "default": 30 + }, + "followProPresenter": { + "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 + }, + "Colors": { + "collapsed": false, + "columns": 2 + }, + "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)", + "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." + }, + "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" }, + "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" } + } +} diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js new file mode 100644 index 0000000..0b3c937 --- /dev/null +++ b/modules/pco-plan/server.js @@ -0,0 +1,913 @@ +'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 /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 + * 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. 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. + */ + +const http = require('http'); +const fs = require('fs'); +const os = require('os'); +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 } + }; +} + +/** + * 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; + const home = os.homedir(); + const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim(); + const root = path.join(__dirname, '..', '..'); + const dataDir = fromEnv + ? path.resolve(root, fromEnv) + : process.platform === 'win32' + ? path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash') + : process.platform === 'darwin' + ? path.join(home, 'Library', 'Application Support', 'ProdDash') + : path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash'); + for (const file of [path.join(dataDir, 'proddash.json'), path.join(root, 'config', 'proddash.json')]) { + try { + const port = Number(JSON.parse(fs.readFileSync(file, 'utf8')).port); + if (port) return port; + } catch { /* try the next one */ } + } + return 24500; +} + +/** Port a browser used to reach us, from a Host header ("10.3.8.41:24500"). */ +function portFromHost(hostHeader) { + const m = /:(\d+)$/.exec(String(hostHeader || '').trim()); + if (m) return Number(m[1]) || 0; + return hostHeader ? 80 : 0; +} + +function localYmd(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +/** 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)}`; + } + + /** The plans around now for a service type: a few upcoming, a few recent, oldest first. */ + async function listPlans(stId, { future = 5, past = 3 } = {}) { + const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`; + const [fut, pst] = await Promise.all([ + pco.get(base, { filter: 'future', order: 'sort_date', per_page: future }), + pco.get(base, { filter: 'past', order: '-sort_date', per_page: past }), + ]); + const byId = new Map(); + for (const rec of [...(fut?.data || []), ...(pst?.data || [])]) { + const sortDate = Date.parse(rec?.attributes?.sort_date || ''); + if (!rec?.id || !Number.isFinite(sortDate)) continue; + byId.set(String(rec.id), { rec, sortDate }); + } + return Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate); + } + + /** Today's plan, else the next upcoming one, else the most recent past one. */ + function pickPlan(plans) { + if (!plans.length) return null; + const now = Date.now(); + const today = localYmd(new Date(now)); + const todays = plans.find((p) => localYmd(new Date(p.sortDate)) === today); + if (todays) return todays.rec; + const next = plans.find((p) => p.sortDate > now); + if (next) return next.rec; + return plans[plans.length - 1].rec; + } + + async function choosePlanId(stId) { + return pickPlan(await listPlans(stId)); + } + + async function readPlanTimes(planId) { + const body = await pco.getAll(`${planPath(planId)}/plan_times`); + return (body.data || []) + .map((t) => ({ + id: String(t.id), + name: String(t.attributes?.name || '').trim(), + timeType: String(t.attributes?.time_type || 'service'), + startsAt: Date.parse(t.attributes?.starts_at || '') || 0, + endsAt: Date.parse(t.attributes?.ends_at || '') || 0, + })) + .filter((t) => t.startsAt) + .sort((x, y) => x.startsAt - y.startsAt); + } + + async function readItems(planId) { + const target = `${planPath(planId)}/items`; + try { + return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' })); + } catch (err) { + // An account/API that refuses one of the includes still has items. + if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) { + log(`items include rejected (${err.message}) — reading items without includes`); + return shapeItems(await pco.getAll(target)); + } + throw err; + } + } + + 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; + 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; + 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 = nnPort; + const req = http.get( + { host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + const why = res.statusCode === 404 + ? 'ProPresenter module is disabled or not installed' + : `ProPresenter module feed answered ${res.statusCode}`; + return nowNextUnavailable(why, NOW_NEXT_MISSING_RETRY_MS); + } + res.setEncoding('utf8'); + let buffer = ''; + res.on('data', (chunk) => { + buffer += chunk; + let idx; + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const block = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + let event = 'message'; + let data = ''; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data += line.slice(5).trim(); + } + if (event !== 'state' || !data) continue; + try { + applyLiveView(JSON.parse(data)); + } catch { /* a bad frame is not fatal */ } + } + }); + res.on('end', () => nowNextUnavailable('ProPresenter module feed ended — reconnecting', NOW_NEXT_RETRY_MS)); + res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS)); + } + ); + req.on('error', (err) => { + if (req !== nnRequest) return; // superseded by a reconnect on the learned port + if (port !== guessedPort) { + // The Host header lied (reverse proxy, port forward) — that port is + // not this ProdDash. Go back to the guess and never trust it again. + badPorts.add(port); + nnPort = guessedPort; + log(`port ${port} refused the loopback (${err.message}) — back to port ${guessedPort}`); + } + nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS); + }); + req.setTimeout(45000, () => req.destroy(new Error('feed went silent'))); + nnRequest = req; + } + + /* — start — */ + + if (!pco.configured) { + state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin'; + log(state.lastError); + } else if (!serviceTypeId) { + state.lastError = 'No service type selected — choose one under PCO Plan in /admin'; + log(state.lastError); + } else { + log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`); + schedulePoll(0); + } + 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, + learnPort, + refresh() { + clearTimeout(pollTimer); + return poll(); + }, + listPlans, + pickPlan, + }; + + 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 — pick one in the Service type list below' }; + if (!state.reachable) { + return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' }; + } + const name = state.serviceType.name || serviceTypeId; + const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found'; + const 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) => { + 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', + 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) }) + ); + }, + + /** + * 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, 200, { options: [], error: 'No Planning Center credentials yet.' }); + try { + const [folderBody, stBody] = await Promise.all([ + pco.getAll(`${SERVICES}/folders`), + pco.getAll(`${SERVICES}/service_types`), + ]); + const folders = new Map(); + for (const f of folderBody.data || []) { + folders.set(String(f.id), { + id: String(f.id), + name: String(f.attributes?.name || 'Folder'), + parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '', + }); + } + const serviceTypes = (stBody.data || []).map((st) => ({ + id: String(st.id), + name: String(st.attributes?.name || 'Service type'), + frequency: String(st.attributes?.frequency || ''), + parentId: st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '', + })); + // Fill in any folder the list did not include (some accounts only + // list root folders) by fetching the missing parents individually. + const missing = new Set(); + for (const st of serviceTypes) if (st.parentId && !folders.has(st.parentId)) missing.add(st.parentId); + for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) missing.add(f.parentId); + let guard = 0; + while (missing.size && guard < 50) { + const id = missing.values().next().value; + missing.delete(id); + guard += 1; + try { + const one = (await pco.get(`${SERVICES}/folders/${encodeURIComponent(id)}`))?.data; + const parentId = one?.relationships?.parent?.data?.id ? String(one.relationships.parent.data.id) : ''; + folders.set(id, { id, name: String(one?.attributes?.name || 'Folder'), parentId }); + if (parentId && !folders.has(parentId)) missing.add(parentId); + } catch { + folders.set(id, { id, name: `Folder ${id}`, parentId: '' }); + } + } + // "Campuses › North Campus" — the folder path from the root down. + const pathOf = (parentId) => { + const names = []; + let id = parentId; + const seen = new Set(); + while (id && folders.has(id) && !seen.has(id)) { + seen.add(id); + names.unshift(folders.get(id).name); + id = folders.get(id).parentId; + } + return names.join(' › '); + }; + const options = serviceTypes + .map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) })) + .sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label)); + json(res, 200, { options }); + } catch (err) { + log(`service type list failed: ${err?.message || err}`); + json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) }); + } + }, + + /** + * Options for the admin page's "Plan" select, for the service type the + * admin currently has picked (?serviceTypeId=…): the upcoming and recent + * plans, with the one Automatic would choose marked. + */ + 'GET /plans': async (req, res) => { + if (!current) return json(res, 503, { error: 'Module not running.' }); + const { pco } = current; + const params = new URLSearchParams(req.search || ''); + const stId = String(params.get('serviceTypeId') || '').trim(); + const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' }; + if (!pco.configured || !stId) return json(res, 200, { options: [auto] }); + try { + const plans = await current.listPlans(stId, { future: 8, past: 6 }); + const picked = current.pickPlan(plans); + const now = Date.now(); + const options = [auto]; + // Upcoming first (soonest at the top), then the recent ones. + const ordered = [ + ...plans.filter((p) => p.sortDate >= now - 12 * 3600000), + ...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(), + ]; + for (const { rec, sortDate } of ordered) { + const a = rec.attributes || {}; + const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())]; + if (a.series_title) bits.push(String(a.series_title)); + if (a.title) bits.push(String(a.title)); + let label = bits.join(' — '); + if (picked && rec.id === picked.id) label += ' (Automatic picks this)'; + options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' }); + } + json(res, 200, { options }); + } catch (err) { + json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) }); + } + }, + }; + }, +}; diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css new file mode 100644 index 0000000..a808d56 --- /dev/null +++ b/modules/pco-plan/style.css @@ -0,0 +1,367 @@ +/* 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; + --pp-header: var(--accent); + --pp-current: var(--accent); + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + font-size: var(--pp-size); + color: var(--text); +} + +/* ── top bar ── */ + +[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.1em; + font-weight: 700; + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-module="pco-plan"] .pp-plan-sub { + margin-top: 0.15em; + 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; + 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); } + +/* 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 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: 0.9em -0.5em 0; + padding: 0.5em 1em 0.45em calc(1em + 3px); + align-items: center; + 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 .pp-title { + font-size: 0.85em; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + 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)); } + +[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(--pp-current); + background: color-mix(in srgb, var(--pp-current) 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; + 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-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; } + +[data-module="pco-plan"] .pp-title-line { + display: flex; + align-items: center; + 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; +} + +/* 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; + border-radius: 999px; + background: var(--pp-current); + color: var(--bg); + font-size: 0.65em; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} +[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-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-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; + 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-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/package.json b/package.json index 6875fc7..15fe326 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/admin/admin.css b/public/admin/admin.css index e3dae18..bb9816f 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -130,6 +130,50 @@ body { overflow: auto; } } .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. */ diff --git a/public/admin/admin.js b/public/admin/admin.js index ac58407..f44bb81 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -107,6 +107,10 @@ function fieldFor(key, spec, value, passwordSet, moduleId) { // 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 === 'switch') { // Same toggle as the module enable switch, inline with its label. @@ -161,36 +165,76 @@ function fieldFor(key, spec, value, passwordSet, moduleId) { const span = document.createElement('span'); span.textContent = label; const select = document.createElement('select'); + 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). - const saved = String(value ?? ''); - if (saved && !options.some((opt) => String(typeof opt === 'object' ? opt.value : opt) === saved)) { + if (saved && !options.some((opt) => optValue(opt) === saved)) { options = [{ value: saved, label: `${saved} (saved)` }, ...options]; } - if (!saved) options = [{ value: '', label: '— choose —' }, ...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 = String(typeof opt === 'object' ? opt.value : opt); + o.value = optValue(opt); o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); - select.appendChild(o); + 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 = saved; + 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. - fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}`) - .then((res) => (res.ok ? res.json() : null)) - .then((body) => { - if (Array.isArray(body?.options)) fill(body.options); - }) - .catch(() => { /* keep the static/saved options */ }); + // 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(); } 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; @@ -205,7 +249,32 @@ function fieldFor(key, spec, value, passwordSet, moduleId) { field.append(span, input); read = () => (type === 'number' ? Number(input.value) : input.value); } - return { field, read, control }; + 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) { @@ -319,32 +388,62 @@ function buildModuleCard(mod) { 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. + // 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 g = spec?.group || ''; if (g !== groupName) { groupName = g; + currentGroup = null; if (g) { - const group = document.createElement('div'); + 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('div'); + 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 = group; + container = body; + currentGroup = { el: group, meta, fields: [] }; + if (folds) collapsibles.push(currentGroup); } else { container = form; } } - const { field, read, control } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id); + const { field, read, control, selectEl, refetch } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id); readers.set(key, read); 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. @@ -355,6 +454,22 @@ function buildModuleCard(mod) { 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'; const save = document.createElement('button'); @@ -380,6 +495,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/public/shell.js b/public/shell.js index 3936ede..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()); } @@ -934,11 +942,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' : ''); @@ -961,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; @@ -970,7 +1031,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 750f6ad..2ff8649 100644 --- a/public/style.css +++ b/public/style.css @@ -547,6 +547,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; @@ -580,6 +644,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 { diff --git a/server.js b/server.js index c30e9f9..5655907 100644 --- a/server.js +++ b/server.js @@ -508,6 +508,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), @@ -973,6 +974,7 @@ function adminModuleView(id) { incompatible: man.incompatible || '', enabled: isEnabled(id), configSchema: man.configSchema || {}, + configGroups: man.configGroups && typeof man.configGroups === 'object' ? man.configGroups : {}, config: values, passwordSet, mountError: entry?.error || '', diff --git a/tools/pco-mock.js b/tools/pco-mock.js new file mode 100644 index 0000000..73da9c2 --- /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, 'Part 1', 'Living Hope'), + mk(`${stId}-P2`, firstServiceStart, 'Part 2', 'Living Hope'), + mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Part 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); +});