PCO Plan module: Planning Center order of service, following ProPresenter
New module modules/pco-plan: reads the current plan for a chosen Planning Center Services service type (PAT or OAuth token), streams items with start times, lengths, keys, descriptions and item notes to tiles (every detail a per-tile show/hide setting), and follows the propresenter-now-next module's live item over loopback SSE to highlight the current plan item and track elapsed vs planned runtime. Ships its own setup page (/modules/pco-plan/setup.html) to browse folders and pick the service type through the shell admin API, plus tools/pco-mock.js for development. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a3a68ebd73
commit
e7dc235a8a
@@ -0,0 +1,558 @@
|
||||
/* PCO Plan — client part.
|
||||
|
||||
The server part reads Planning Center and (optionally) follows the
|
||||
ProPresenter module's live item; this tile only renders the plan it is
|
||||
streamed. Which details show is a per-tile choice (gear menu). */
|
||||
|
||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||
const SETUP_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h18M3 12h18M3 17h12"/><circle cx="19" cy="17" r="2.2"/></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 = `
|
||||
<div class="pp-wrap">
|
||||
<header class="pp-head">
|
||||
<div class="pp-plan">
|
||||
<div class="pp-plan-title"></div>
|
||||
<div class="pp-plan-sub"></div>
|
||||
</div>
|
||||
<div class="pp-clock">
|
||||
<div class="pp-clock-label"></div>
|
||||
<div class="pp-clock-value"></div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="pp-list"></div>
|
||||
<footer class="pp-foot" hidden></footer>
|
||||
</div>`;
|
||||
|
||||
const wrap = root.querySelector('.pp-wrap');
|
||||
const headEl = root.querySelector('.pp-head');
|
||||
const planTitleEl = root.querySelector('.pp-plan-title');
|
||||
const planSubEl = root.querySelector('.pp-plan-sub');
|
||||
const clockLabelEl = root.querySelector('.pp-clock-label');
|
||||
const clockValueEl = root.querySelector('.pp-clock-value');
|
||||
const listEl = root.querySelector('.pp-list');
|
||||
const footEl = root.querySelector('.pp-foot');
|
||||
|
||||
const prefs = () => moduleApi.instanceSettings;
|
||||
const serverNow = () => Date.now() + skew;
|
||||
|
||||
/* ── title-bar controls ─────────────────────────────────────────── */
|
||||
|
||||
const refreshBtn = moduleApi.header.addButton({
|
||||
icon: REFRESH_SVG,
|
||||
title: 'Re-read the plan from Planning Center now',
|
||||
onClick() {
|
||||
refreshBtn.classList.add('active');
|
||||
moduleApi.fetch('/refresh', { method: 'POST' })
|
||||
.catch(() => { /* the stream reports the outcome */ })
|
||||
.finally(() => refreshBtn.classList.remove('active'));
|
||||
},
|
||||
});
|
||||
|
||||
function bumpTextSize(delta) {
|
||||
const size = Number(prefs().textSize) || 14;
|
||||
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
|
||||
applyPrefs();
|
||||
}
|
||||
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
|
||||
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
|
||||
|
||||
moduleApi.header.addButton({
|
||||
icon: SETUP_SVG,
|
||||
title: 'Planning Center setup (choose the service type)',
|
||||
onClick() {
|
||||
window.open(`/modules/${moduleApi.id}/setup.html`, '_blank', 'noopener');
|
||||
},
|
||||
});
|
||||
|
||||
/* ── formatting ─────────────────────────────────────────────────── */
|
||||
|
||||
function fmtClock(ms) {
|
||||
if (!ms) return '';
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtDur(totalSec, { signed = false } = {}) {
|
||||
const sign = totalSec < 0 ? '-' : (signed ? '+' : '');
|
||||
const s = Math.abs(Math.round(totalSec));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h) return `${sign}${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||
return `${sign}${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function fmtMinutes(totalSec) {
|
||||
const m = Math.round(totalSec / 60);
|
||||
if (m < 60) return `${m} min`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rest = m % 60;
|
||||
return rest ? `${h} h ${rest} min` : `${h} h`;
|
||||
}
|
||||
|
||||
function typeLabel(itemType) {
|
||||
switch (itemType) {
|
||||
case 'song': return 'Song';
|
||||
case 'media': return 'Media';
|
||||
case 'header': return '';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── which items to show ────────────────────────────────────────── */
|
||||
|
||||
function visibleItems() {
|
||||
const p = prefs();
|
||||
const items = Array.isArray(state?.items) ? state.items : [];
|
||||
return items.filter((it) => {
|
||||
if (it.itemType === 'header' && !p.showHeaders) return false;
|
||||
if (it.servicePosition === 'pre' && !p.showPreService) return false;
|
||||
if (it.servicePosition === 'post' && !p.showPostService) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function noteFilter() {
|
||||
const raw = String(prefs().noteCategories || '');
|
||||
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
|
||||
}
|
||||
|
||||
/* ── status dot ─────────────────────────────────────────────────── */
|
||||
|
||||
function renderStatus() {
|
||||
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
||||
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
||||
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
||||
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — use the setup page');
|
||||
if (!state.reachable) {
|
||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
||||
}
|
||||
const live = state.live || {};
|
||||
let extra = '';
|
||||
if (live.following) {
|
||||
extra = live.available
|
||||
? (live.ppReachable ? ' · following ProPresenter' : ' · ProPresenter unreachable')
|
||||
: ` · ${live.reason || 'ProPresenter feed unavailable'}`;
|
||||
}
|
||||
moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`);
|
||||
}
|
||||
|
||||
/* ── plan header ────────────────────────────────────────────────── */
|
||||
|
||||
function renderHead() {
|
||||
const plan = state?.plan;
|
||||
const show = prefs().showPlanHeader && plan;
|
||||
headEl.hidden = !show;
|
||||
if (!show) return;
|
||||
const title = [plan.dates, plan.title || plan.seriesTitle].filter(Boolean).join(' · ');
|
||||
planTitleEl.textContent = title || (state.serviceType?.name || 'Plan');
|
||||
const bits = [];
|
||||
if (state.serviceType?.name) bits.push(state.serviceType.name);
|
||||
if (plan.title && plan.seriesTitle) bits.push(plan.seriesTitle);
|
||||
if (plan.serviceStartsAt) bits.push(`${plan.serviceName ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`);
|
||||
const planned = state.items.filter((it) => it.servicePosition === 'during').reduce((sum, it) => sum + (it.length || 0), 0);
|
||||
if (planned) bits.push(`planned ${fmtMinutes(planned)}`);
|
||||
planSubEl.textContent = bits.join(' · ');
|
||||
renderClock();
|
||||
}
|
||||
|
||||
function renderClock() {
|
||||
const plan = state?.plan;
|
||||
const live = state?.live || {};
|
||||
clockLabelEl.textContent = '';
|
||||
clockValueEl.textContent = '';
|
||||
clockValueEl.className = 'pp-clock-value';
|
||||
if (!plan || headEl.hidden) return;
|
||||
const now = serverNow();
|
||||
if (prefs().showRuntime && live.serviceStartedAt) {
|
||||
clockLabelEl.textContent = 'Running';
|
||||
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000);
|
||||
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
|
||||
const planned = state.items
|
||||
.filter((it) => live.history?.[it.id]?.endedAt)
|
||||
.reduce((sum, it) => sum + (it.length || 0), 0);
|
||||
const actual = state.items
|
||||
.filter((it) => live.history?.[it.id]?.endedAt)
|
||||
.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
|
||||
const drift = actual - planned;
|
||||
if (planned && Math.abs(drift) >= 30) {
|
||||
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
|
||||
clockValueEl.classList.add(drift > 0 ? 'is-behind' : 'is-ahead');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (plan.serviceStartsAt && plan.serviceStartsAt > now) {
|
||||
clockLabelEl.textContent = 'Starts in';
|
||||
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
|
||||
return;
|
||||
}
|
||||
if (plan.serviceStartsAt) {
|
||||
clockLabelEl.textContent = 'Since start';
|
||||
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the list ───────────────────────────────────────────────────── */
|
||||
|
||||
function emptyMessage() {
|
||||
if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
|
||||
if (!state) return { title: 'Connecting…', hint: '' };
|
||||
if (!state.configured) {
|
||||
return {
|
||||
title: 'Planning Center not connected',
|
||||
hint: 'Add a Planning Center Application ID and Secret (Personal Access Token) for PCO Plan in Admin.',
|
||||
link: { href: '/admin', text: 'Open Admin' },
|
||||
};
|
||||
}
|
||||
if (!state.serviceTypeSet) {
|
||||
return {
|
||||
title: 'No service type selected',
|
||||
hint: 'Browse your Planning Center folders and choose the service type to follow.',
|
||||
link: { href: `/modules/${moduleApi.id}/setup.html`, text: 'Open setup' },
|
||||
};
|
||||
}
|
||||
if (!state.reachable && !state.plan) {
|
||||
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
||||
}
|
||||
if (!state.plan) {
|
||||
return {
|
||||
title: 'No plan found',
|
||||
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
|
||||
};
|
||||
}
|
||||
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings.' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildRow(item, p, notesWanted) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pp-row';
|
||||
row.dataset.id = item.id;
|
||||
if (item.itemType === 'header') row.classList.add('is-header');
|
||||
if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`);
|
||||
|
||||
const timeCol = document.createElement('div');
|
||||
timeCol.className = 'pp-col-time';
|
||||
timeCol.textContent = p.showStartTimes && item.startsAt && item.itemType !== 'header' ? fmtClock(item.startsAt) : '';
|
||||
timeCol.hidden = !p.showStartTimes;
|
||||
row.appendChild(timeCol);
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'pp-col-main';
|
||||
const titleLine = document.createElement('div');
|
||||
titleLine.className = 'pp-title-line';
|
||||
const titleEl = document.createElement('span');
|
||||
titleEl.className = 'pp-title';
|
||||
titleEl.textContent = item.title || item.song?.title || '(untitled)';
|
||||
titleLine.appendChild(titleEl);
|
||||
if (item.itemType !== 'header') {
|
||||
const nowTag = document.createElement('span');
|
||||
nowTag.className = 'pp-now-tag';
|
||||
nowTag.textContent = 'Now';
|
||||
titleLine.appendChild(nowTag);
|
||||
if (p.showItemType && typeLabel(item.itemType)) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `pp-badge is-${item.itemType}`;
|
||||
badge.textContent = typeLabel(item.itemType);
|
||||
titleLine.appendChild(badge);
|
||||
}
|
||||
if (p.showKey && item.keyName) {
|
||||
const key = document.createElement('span');
|
||||
key.className = 'pp-key';
|
||||
key.textContent = item.keyName;
|
||||
titleLine.appendChild(key);
|
||||
}
|
||||
if (p.showArrangement && item.arrangement) {
|
||||
const arr = document.createElement('span');
|
||||
arr.className = 'pp-arr';
|
||||
arr.textContent = item.arrangement;
|
||||
titleLine.appendChild(arr);
|
||||
}
|
||||
}
|
||||
main.appendChild(titleLine);
|
||||
|
||||
if (p.showDescription && item.description) {
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'pp-desc';
|
||||
desc.textContent = item.description;
|
||||
main.appendChild(desc);
|
||||
}
|
||||
if (p.showNotes && item.notes?.length) {
|
||||
const notes = item.notes.filter(notesWanted);
|
||||
if (notes.length) {
|
||||
const notesEl = document.createElement('div');
|
||||
notesEl.className = 'pp-notes';
|
||||
for (const note of notes) {
|
||||
const n = document.createElement('div');
|
||||
n.className = 'pp-note';
|
||||
if (note.category) {
|
||||
const cat = document.createElement('span');
|
||||
cat.className = 'pp-note-cat';
|
||||
cat.textContent = note.category;
|
||||
n.appendChild(cat);
|
||||
}
|
||||
n.appendChild(document.createTextNode(note.content));
|
||||
notesEl.appendChild(n);
|
||||
}
|
||||
main.appendChild(notesEl);
|
||||
}
|
||||
}
|
||||
row.appendChild(main);
|
||||
|
||||
const lenCol = document.createElement('div');
|
||||
lenCol.className = 'pp-col-len';
|
||||
if (item.itemType !== 'header') {
|
||||
if (p.showLength) {
|
||||
const len = document.createElement('div');
|
||||
len.className = 'pp-len';
|
||||
len.textContent = item.length ? fmtDur(item.length) : '';
|
||||
lenCol.appendChild(len);
|
||||
}
|
||||
if (p.showRuntime && state?.live?.following) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pp-elapsed';
|
||||
lenCol.appendChild(el);
|
||||
}
|
||||
}
|
||||
lenCol.hidden = !p.showLength && !(p.showRuntime && state?.live?.following);
|
||||
row.appendChild(lenCol);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const empty = emptyMessage();
|
||||
if (empty) {
|
||||
listEl.innerHTML = '';
|
||||
lastListKey = '';
|
||||
const box = document.createElement('div');
|
||||
box.className = 'pp-empty';
|
||||
const t = document.createElement('div');
|
||||
t.className = 'pp-empty-title';
|
||||
t.textContent = empty.title;
|
||||
box.appendChild(t);
|
||||
if (empty.hint) {
|
||||
const h = document.createElement('div');
|
||||
h.className = 'pp-empty-hint';
|
||||
h.textContent = empty.hint;
|
||||
box.appendChild(h);
|
||||
}
|
||||
if (empty.link) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'pp-empty-link';
|
||||
a.href = empty.link.href;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = empty.link.text;
|
||||
box.appendChild(a);
|
||||
}
|
||||
listEl.appendChild(box);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild rows only when the plan content or the display prefs changed;
|
||||
// live highlighting and timers are applied in place.
|
||||
const p = prefs();
|
||||
const key = JSON.stringify([state.plan.id, state.items, p, state.live?.following]);
|
||||
if (key !== lastListKey) {
|
||||
lastListKey = key;
|
||||
listEl.innerHTML = '';
|
||||
const notesWanted = noteFilter();
|
||||
for (const item of visibleItems()) listEl.appendChild(buildRow(item, p, notesWanted));
|
||||
lastCurrentId = null; // force the highlight + scroll pass
|
||||
}
|
||||
renderLiveMarks();
|
||||
}
|
||||
|
||||
/** Current / done classes and the elapsed timers, without rebuilding rows. */
|
||||
function renderLiveMarks() {
|
||||
const live = state?.live || {};
|
||||
const p = prefs();
|
||||
const now = serverNow();
|
||||
const currentId = live.following ? live.currentItemId || '' : '';
|
||||
const history = live.history || {};
|
||||
const byId = new Map(state.items.map((it) => [it.id, it]));
|
||||
const rows = listEl.querySelectorAll('.pp-row');
|
||||
// Everything before the current item counts as done, matched or not.
|
||||
let seenCurrent = !currentId;
|
||||
const doneIds = new Set();
|
||||
if (currentId) {
|
||||
for (const it of state.items) {
|
||||
if (it.id === currentId) break;
|
||||
if (it.itemType !== 'header') doneIds.add(it.id);
|
||||
}
|
||||
}
|
||||
for (const row of rows) {
|
||||
const id = row.dataset.id;
|
||||
const item = byId.get(id);
|
||||
const isCurrent = id === currentId;
|
||||
row.classList.toggle('is-current', isCurrent);
|
||||
row.classList.toggle('is-done', p.dimCompleted && doneIds.has(id));
|
||||
if (isCurrent) seenCurrent = true;
|
||||
const elapsedEl = row.querySelector('.pp-elapsed');
|
||||
if (!elapsedEl || !item) continue;
|
||||
const h = history[id];
|
||||
elapsedEl.className = 'pp-elapsed';
|
||||
if (!h) {
|
||||
elapsedEl.textContent = '';
|
||||
continue;
|
||||
}
|
||||
const elapsedSec = ((h.endedAt || now) - h.startedAt) / 1000;
|
||||
elapsedEl.textContent = fmtDur(elapsedSec);
|
||||
if (item.length) {
|
||||
const over = elapsedSec - item.length;
|
||||
if (over > item.length * 0.25 + 30) elapsedEl.classList.add('is-over-hard');
|
||||
else if (over > 0) elapsedEl.classList.add('is-over');
|
||||
}
|
||||
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
||||
}
|
||||
void seenCurrent;
|
||||
if (currentId !== lastCurrentId) {
|
||||
lastCurrentId = currentId;
|
||||
if (currentId && p.autoScroll && Date.now() - userScrolledAt > 8000) {
|
||||
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
|
||||
row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── footer: what ProPresenter is doing ─────────────────────────── */
|
||||
|
||||
function renderFoot() {
|
||||
const live = state?.live;
|
||||
const show = prefs().showLiveFooter && live?.following && state?.plan;
|
||||
footEl.hidden = !show;
|
||||
footEl.className = 'pp-foot';
|
||||
if (!show) return;
|
||||
if (!live.available) {
|
||||
footEl.classList.add('is-muted');
|
||||
footEl.textContent = live.reason || 'ProPresenter feed unavailable';
|
||||
return;
|
||||
}
|
||||
if (!live.ppEnabled) {
|
||||
footEl.classList.add('is-muted');
|
||||
footEl.textContent = 'ProPresenter module has no host configured';
|
||||
return;
|
||||
}
|
||||
if (!live.ppReachable) {
|
||||
footEl.classList.add('is-down');
|
||||
footEl.textContent = 'ProPresenter unreachable';
|
||||
return;
|
||||
}
|
||||
if (!live.activeName) {
|
||||
footEl.classList.add('is-muted');
|
||||
footEl.textContent = 'Nothing live in ProPresenter';
|
||||
return;
|
||||
}
|
||||
if (live.matched) {
|
||||
footEl.classList.add('is-live');
|
||||
footEl.textContent = `Live: ${live.activeName}`;
|
||||
} else {
|
||||
footEl.classList.add('is-unmatched');
|
||||
footEl.textContent = `Live: ${live.activeName} — not in the plan`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── render + ticking ───────────────────────────────────────────── */
|
||||
|
||||
function render() {
|
||||
wrap.classList.toggle('is-offline', feedOffline);
|
||||
renderStatus();
|
||||
if (!state) {
|
||||
headEl.hidden = true;
|
||||
renderList();
|
||||
footEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
renderHead();
|
||||
renderList();
|
||||
renderFoot();
|
||||
}
|
||||
|
||||
function applyPrefs() {
|
||||
const size = Math.min(40, Math.max(9, Number(prefs().textSize) || 14));
|
||||
wrap.style.setProperty('--pp-size', `${size}px`);
|
||||
lastListKey = '';
|
||||
render();
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!state?.plan) return;
|
||||
renderClock();
|
||||
if (state.live?.following && prefs().showRuntime) renderLiveMarks();
|
||||
}
|
||||
|
||||
/* ── live feed ──────────────────────────────────────────────────── */
|
||||
|
||||
function accept(payload) {
|
||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||
state = payload.state;
|
||||
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
||||
feedOffline = false;
|
||||
render();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
stream = moduleApi.sse('/stream', {
|
||||
open() {
|
||||
feedOffline = false;
|
||||
render();
|
||||
},
|
||||
error() {
|
||||
feedOffline = true;
|
||||
render();
|
||||
},
|
||||
events: {
|
||||
state(ev) {
|
||||
try {
|
||||
accept(JSON.parse(ev.data));
|
||||
} catch { /* a bad frame is not fatal */ }
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onUserScroll() {
|
||||
userScrolledAt = Date.now();
|
||||
}
|
||||
|
||||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
||||
|
||||
return {
|
||||
start() {
|
||||
applyPrefs();
|
||||
moduleApi.setStatus('connecting', 'Connecting…');
|
||||
moduleApi.fetch('/state')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((body) => { if (body && !state) accept(body); })
|
||||
.catch(() => { /* the stream will cover it */ });
|
||||
connect();
|
||||
listEl.addEventListener('wheel', onUserScroll, { passive: true });
|
||||
listEl.addEventListener('touchmove', onUserScroll, { passive: true });
|
||||
ticker = setInterval(tick, 1000);
|
||||
},
|
||||
stop() {
|
||||
clearInterval(ticker);
|
||||
ticker = null;
|
||||
stream?.close();
|
||||
stream = null;
|
||||
listEl.removeEventListener('wheel', onUserScroll);
|
||||
listEl.removeEventListener('touchmove', onUserScroll);
|
||||
root.innerHTML = '';
|
||||
},
|
||||
onConfigChange() {
|
||||
// The server re-inits and its stream re-sends the state; nothing to cache here.
|
||||
lastListKey = '';
|
||||
render();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"id": "pco-plan",
|
||||
"name": "PCO Plan",
|
||||
"version": "1.0.0",
|
||||
"description": "Order of service from Planning Center Services, following ProPresenter live. Pick the service type at /modules/pco-plan/setup.html",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 3, "h": 3 },
|
||||
"defaultSize": { "w": 5, "h": 6 },
|
||||
"configSchema": {
|
||||
"appId": {
|
||||
"type": "string",
|
||||
"label": "Planning Center Application ID (Personal Access Token)",
|
||||
"default": ""
|
||||
},
|
||||
"secret": {
|
||||
"type": "password",
|
||||
"label": "Planning Center Secret (Personal Access Token)",
|
||||
"default": ""
|
||||
},
|
||||
"accessToken": {
|
||||
"type": "password",
|
||||
"label": "OAuth 2.0 access token (alternative to App ID + Secret)",
|
||||
"default": ""
|
||||
},
|
||||
"serviceTypeId": {
|
||||
"type": "string",
|
||||
"label": "Service type ID — browse folders and pick one at /modules/pco-plan/setup.html",
|
||||
"default": ""
|
||||
},
|
||||
"planId": {
|
||||
"type": "string",
|
||||
"label": "Pin a plan ID (blank = today's plan, else the next upcoming)",
|
||||
"default": ""
|
||||
},
|
||||
"pollSeconds": {
|
||||
"type": "number",
|
||||
"label": "Re-read the plan every N seconds",
|
||||
"default": 30
|
||||
},
|
||||
"followProPresenter": {
|
||||
"type": "boolean",
|
||||
"label": "Follow the ProPresenter module's live item to highlight the current plan item",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showPlanHeader": { "type": "boolean", "label": "Show plan title, date and service time", "default": true },
|
||||
"showStartTimes": { "type": "boolean", "label": "Show scheduled start time of each item", "default": true },
|
||||
"showLength": { "type": "boolean", "label": "Show planned length of each item", "default": true },
|
||||
"showItemType": { "type": "boolean", "label": "Show item type badge (song / media)", "default": true },
|
||||
"showKey": { "type": "boolean", "label": "Show song key", "default": true },
|
||||
"showArrangement": { "type": "boolean", "label": "Show song arrangement", "default": false },
|
||||
"showDescription": { "type": "boolean", "label": "Show item description", "default": true },
|
||||
"showNotes": { "type": "boolean", "label": "Show item notes", "default": true },
|
||||
"noteCategories": { "type": "string", "label": "Only these note categories (comma-separated, blank = all)", "default": "" },
|
||||
"showHeaders": { "type": "boolean", "label": "Show section headers", "default": true },
|
||||
"showPreService": { "type": "boolean", "label": "Show pre-service items", "default": true },
|
||||
"showPostService": { "type": "boolean", "label": "Show post-service items", "default": true },
|
||||
"showRuntime": { "type": "boolean", "label": "Show live runtime (elapsed vs planned) when following ProPresenter", "default": true },
|
||||
"showLiveFooter": { "type": "boolean", "label": "Show the ProPresenter match line at the bottom", "default": true },
|
||||
"dimCompleted": { "type": "boolean", "label": "Dim items that are already done", "default": true },
|
||||
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
||||
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* PCO Plan — server part.
|
||||
*
|
||||
* Owns the one Planning Center Services connection, keeps the current plan
|
||||
* (order of service) for the configured service type in memory, and fans it
|
||||
* out to every tile over SSE. Browsers never talk to Planning Center.
|
||||
*
|
||||
* GET /state → { state, now } (instant paint for new tiles)
|
||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||
* POST /refresh → re-read the plan now
|
||||
* GET /browse → folders + service types, for the setup page
|
||||
* GET /preview?serviceTypeId=… → which plan the module would pick
|
||||
*
|
||||
* Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
|
||||
* Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0
|
||||
* bearer token. Responses are JSON:API — `data`, `included`, `links.next`.
|
||||
* GET /folders, /service_types (setup browsing)
|
||||
* GET /service_types/{st}/plans?filter=future|past (pick the plan)
|
||||
* GET /service_types/{st}/plans/{id} (title, dates…)
|
||||
* GET …/plans/{id}/plan_times (service start times)
|
||||
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
||||
*
|
||||
* Phase 2 — following ProPresenter: instead of talking to ProPresenter a
|
||||
* second time, this module reads the propresenter-now-next module's own SSE
|
||||
* feed over loopback (/api/modules/propresenter-now-next/stream) and matches
|
||||
* the live presentation name to a plan item. Item start/end timestamps are
|
||||
* tracked here, once, so every tile agrees on the runtime.
|
||||
*
|
||||
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 points the module at
|
||||
* tools/pco-mock.js for development.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||||
const API_BASE = String(process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
const SERVICES = '/services/v2';
|
||||
|
||||
/** Abort a hung Planning Center request well before the poll cadence. */
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
/** Fastest allowed plan re-read (PCO rate limit is 100 requests / 20 s per app). */
|
||||
const MIN_POLL_S = 10;
|
||||
/** Backoff after a failed plan read. */
|
||||
const RETRY_MS = 15000;
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
/** The Now/Next module whose feed we follow. */
|
||||
const NOW_NEXT_ID = 'propresenter-now-next';
|
||||
const NOW_NEXT_RETRY_MS = 3000;
|
||||
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||
/** Minimum name-similarity to call a ProPresenter document a plan item. */
|
||||
const MATCH_THRESHOLD = 0.6;
|
||||
|
||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||
let current = null;
|
||||
|
||||
/* ── small helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
function clearedState() {
|
||||
return {
|
||||
configured: false, // credentials present
|
||||
serviceTypeSet: false, // a service type is chosen
|
||||
reachable: false, // last Planning Center read succeeded
|
||||
lastError: '',
|
||||
lastFetched: 0,
|
||||
apiBase: API_BASE,
|
||||
serviceType: { id: '', name: '' },
|
||||
plan: null,
|
||||
items: [],
|
||||
live: clearedLive(),
|
||||
};
|
||||
}
|
||||
|
||||
function clearedLive() {
|
||||
return {
|
||||
following: false, // admin switch on
|
||||
available: false, // Now/Next feed reachable
|
||||
reason: '', // why not available
|
||||
ppEnabled: false,
|
||||
ppReachable: false,
|
||||
activeName: '', // what ProPresenter has live right now
|
||||
matched: false, // activeName maps to a plan item
|
||||
currentItemId: '',
|
||||
currentScore: 0,
|
||||
serviceStartedAt: 0, // first matched item start, ms epoch
|
||||
history: {}, // itemId → { startedAt, endedAt }
|
||||
};
|
||||
}
|
||||
|
||||
/** The shell's port, so the loopback Now/Next feed lands on this ProdDash. */
|
||||
function shellPort() {
|
||||
const env = Number(process.env.PORT);
|
||||
if (env) return env;
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config', 'proddash.json'), 'utf8'));
|
||||
if (Number(cfg.port)) return Number(cfg.port);
|
||||
} catch { /* fall through */ }
|
||||
return 24500;
|
||||
}
|
||||
|
||||
function localYmd(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */
|
||||
function cleanTitle(raw) {
|
||||
const original = String(raw || '').trim();
|
||||
let s = original;
|
||||
s = s.replace(/[[({][^\])}]*[\])}]/g, ' ');
|
||||
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, '');
|
||||
s = s.replace(/\s[-–—]+\s/g, ' ');
|
||||
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, '');
|
||||
s = s.replace(/\s{2,}/g, ' ').trim();
|
||||
return s || original;
|
||||
}
|
||||
|
||||
function normalize(raw) {
|
||||
return cleanTitle(raw)
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[’'`]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']);
|
||||
function tokens(norm) {
|
||||
return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t)));
|
||||
}
|
||||
|
||||
/** 0..1 similarity between two titles. */
|
||||
function similarity(a, b) {
|
||||
const na = normalize(a);
|
||||
const nb = normalize(b);
|
||||
if (!na || !nb) return 0;
|
||||
if (na === nb) return 1;
|
||||
if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85;
|
||||
const ta = tokens(na);
|
||||
const tb = tokens(nb);
|
||||
if (!ta.size || !tb.size) return 0;
|
||||
let common = 0;
|
||||
for (const t of ta) if (tb.has(t)) common += 1;
|
||||
return (2 * common) / (ta.size + tb.size); // Dice coefficient
|
||||
}
|
||||
|
||||
/* ── Planning Center client ─────────────────────────────────────────── */
|
||||
|
||||
function createPcoClient(config) {
|
||||
const token = String(config.accessToken || '').trim();
|
||||
const appId = String(config.appId || '').trim();
|
||||
const secret = String(config.secret || '');
|
||||
let auth = '';
|
||||
if (token) auth = `Bearer ${token}`;
|
||||
else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
|
||||
|
||||
async function get(target, params) {
|
||||
const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
|
||||
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: { Authorization: auth, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
const e = new Error(`Planning Center unreachable: ${err?.message || err}`);
|
||||
e.network = true;
|
||||
throw e;
|
||||
}
|
||||
if (!response.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail = body?.errors?.[0]?.detail || body?.errors?.[0]?.title || '';
|
||||
} catch { /* no JSON body */ }
|
||||
const e = new Error(
|
||||
response.status === 401
|
||||
? 'Planning Center rejected the credentials (401)'
|
||||
: `Planning Center ${response.status}${detail ? `: ${detail}` : ''}`
|
||||
);
|
||||
e.status = response.status;
|
||||
if (response.status === 429) e.retryAfter = Number(response.headers.get('retry-after')) || 10;
|
||||
throw e;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/** Follow JSON:API `links.next` until the collection is complete. */
|
||||
async function getAll(target, params) {
|
||||
const data = [];
|
||||
const included = [];
|
||||
let url = target;
|
||||
let query = { per_page: 100, ...(params || {}) };
|
||||
for (let page = 0; page < 30; page += 1) {
|
||||
const body = await get(url, query);
|
||||
if (Array.isArray(body?.data)) data.push(...body.data);
|
||||
if (Array.isArray(body?.included)) included.push(...body.included);
|
||||
const next = body?.links?.next;
|
||||
if (!next) break;
|
||||
url = next;
|
||||
query = {}; // `next` already carries the query string
|
||||
}
|
||||
return { data, included };
|
||||
}
|
||||
|
||||
return { configured: Boolean(auth), method: token ? 'oauth' : 'pat', get, getAll };
|
||||
}
|
||||
|
||||
/* ── plan shaping ───────────────────────────────────────────────────── */
|
||||
|
||||
function pickServiceTime(planTimes, nowMs) {
|
||||
const services = planTimes.filter((t) => t.timeType === 'service' && t.startsAt);
|
||||
const pool = services.length ? services : planTimes.filter((t) => t.startsAt);
|
||||
if (!pool.length) return null;
|
||||
pool.sort((a, b) => a.startsAt - b.startsAt);
|
||||
// The service that is running or up next; after the last one, the last one.
|
||||
const upcoming = pool.find((t) => (t.endsAt || t.startsAt + 90 * 60000) > nowMs);
|
||||
return upcoming || pool[pool.length - 1];
|
||||
}
|
||||
|
||||
/** Scheduled start of every item from the chosen service start time. */
|
||||
function scheduleItems(items, serviceStartMs) {
|
||||
for (const item of items) item.startsAt = 0;
|
||||
if (!serviceStartMs) return;
|
||||
let t = serviceStartMs;
|
||||
for (const item of items) {
|
||||
if (item.servicePosition !== 'during') continue;
|
||||
item.startsAt = t;
|
||||
t += (item.length || 0) * 1000;
|
||||
}
|
||||
// Post-service items run on from the end of the service…
|
||||
for (const item of items) {
|
||||
if (item.servicePosition !== 'post') continue;
|
||||
item.startsAt = t;
|
||||
t += (item.length || 0) * 1000;
|
||||
}
|
||||
// …and pre-service items are counted backwards to end at the service start.
|
||||
let back = serviceStartMs;
|
||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||
const item = items[i];
|
||||
if (item.servicePosition !== 'pre') continue;
|
||||
back -= (item.length || 0) * 1000;
|
||||
item.startsAt = back;
|
||||
}
|
||||
}
|
||||
|
||||
function shapeItems(itemsBody) {
|
||||
const included = new Map();
|
||||
for (const rec of itemsBody.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
||||
const rel = (item, name) => item?.relationships?.[name]?.data;
|
||||
const items = (itemsBody.data || []).map((it) => {
|
||||
const a = it.attributes || {};
|
||||
const songRef = rel(it, 'song');
|
||||
const song = songRef ? included.get(`Song/${songRef.id}`) : null;
|
||||
const arrRef = rel(it, 'arrangement');
|
||||
const arrangement = arrRef ? included.get(`Arrangement/${arrRef.id}`) : null;
|
||||
const noteRefs = Array.isArray(rel(it, 'item_notes')) ? rel(it, 'item_notes') : [];
|
||||
const notes = noteRefs
|
||||
.map((ref) => included.get(`ItemNote/${ref.id}`))
|
||||
.filter(Boolean)
|
||||
.map((n) => ({
|
||||
category: String(n.attributes?.category_name || ''),
|
||||
content: String(n.attributes?.content || '').trim(),
|
||||
}))
|
||||
.filter((n) => n.content);
|
||||
return {
|
||||
id: String(it.id),
|
||||
sequence: Number(a.sequence) || 0,
|
||||
title: String(a.title || '').trim(),
|
||||
itemType: String(a.item_type || 'item'),
|
||||
length: Number(a.length) || 0,
|
||||
servicePosition: String(a.service_position || 'during'),
|
||||
description: String(a.description || '').trim(),
|
||||
keyName: String(a.key_name || '').trim(),
|
||||
song: song
|
||||
? {
|
||||
title: String(song.attributes?.title || ''),
|
||||
author: String(song.attributes?.author || ''),
|
||||
ccli: song.attributes?.ccli_number ? String(song.attributes.ccli_number) : '',
|
||||
}
|
||||
: null,
|
||||
arrangement: arrangement ? String(arrangement.attributes?.name || '') : '',
|
||||
notes,
|
||||
startsAt: 0,
|
||||
};
|
||||
});
|
||||
items.sort((x, y) => x.sequence - y.sequence);
|
||||
return items;
|
||||
}
|
||||
|
||||
function shapePlan(planRec, serviceTypeId, planTimes) {
|
||||
const a = planRec?.attributes || {};
|
||||
return {
|
||||
id: String(planRec?.id || ''),
|
||||
serviceTypeId: String(serviceTypeId),
|
||||
title: String(a.title || '').trim(),
|
||||
seriesTitle: String(a.series_title || '').trim(),
|
||||
dates: String(a.dates || '').trim(),
|
||||
shortDates: String(a.short_dates || '').trim(),
|
||||
sortDate: a.sort_date ? Date.parse(a.sort_date) || 0 : 0,
|
||||
totalLength: Number(a.total_length) || 0,
|
||||
itemsCount: Number(a.items_count) || 0,
|
||||
url: String(a.planning_center_url || ''),
|
||||
planTimes,
|
||||
serviceStartsAt: 0,
|
||||
serviceEndsAt: 0,
|
||||
serviceName: '',
|
||||
};
|
||||
}
|
||||
|
||||
/* ── the module ─────────────────────────────────────────────────────── */
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const streams = new Set();
|
||||
const pco = createPcoClient(config);
|
||||
const serviceTypeId = String(config.serviceTypeId || '').trim();
|
||||
const pinnedPlanId = String(config.planId || '').trim();
|
||||
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000;
|
||||
const follow = config.followProPresenter !== false;
|
||||
|
||||
let state = clearedState();
|
||||
state.configured = pco.configured;
|
||||
state.serviceTypeSet = Boolean(serviceTypeId);
|
||||
state.serviceType = { id: serviceTypeId, name: '' };
|
||||
state.live.following = follow;
|
||||
|
||||
let stopped = false;
|
||||
let pollTimer = null;
|
||||
let inFlight = null;
|
||||
let lastFrame = '';
|
||||
|
||||
/* — broadcasting — */
|
||||
|
||||
function frameOf() {
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
|
||||
function broadcast(force) {
|
||||
const frame = frameOf();
|
||||
if (!force && frame === lastFrame) return;
|
||||
lastFrame = frame;
|
||||
if (!streams.size) return;
|
||||
const payload = `event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(payload);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* — plan reading — */
|
||||
|
||||
function planPath(planId) {
|
||||
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
|
||||
}
|
||||
|
||||
/** Today's plan, else the next upcoming one, else the most recent past one. */
|
||||
async function choosePlanId(stId) {
|
||||
const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
|
||||
const [future, past] = await Promise.all([
|
||||
pco.get(base, { filter: 'future', order: 'sort_date', per_page: 5 }),
|
||||
pco.get(base, { filter: 'past', order: '-sort_date', per_page: 3 }),
|
||||
]);
|
||||
const byId = new Map();
|
||||
for (const rec of [...(future?.data || []), ...(past?.data || [])]) {
|
||||
const sortDate = Date.parse(rec?.attributes?.sort_date || '');
|
||||
if (!rec?.id || !Number.isFinite(sortDate)) continue;
|
||||
byId.set(String(rec.id), { rec, sortDate });
|
||||
}
|
||||
const plans = Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
|
||||
if (!plans.length) return null;
|
||||
const now = Date.now();
|
||||
const today = localYmd(new Date(now));
|
||||
const todays = plans.find((p) => localYmd(new Date(p.sortDate)) === today);
|
||||
if (todays) return todays.rec;
|
||||
const next = plans.find((p) => p.sortDate > now);
|
||||
if (next) return next.rec;
|
||||
return plans[plans.length - 1].rec;
|
||||
}
|
||||
|
||||
async function readPlanTimes(planId) {
|
||||
const body = await pco.getAll(`${planPath(planId)}/plan_times`);
|
||||
return (body.data || [])
|
||||
.map((t) => ({
|
||||
id: String(t.id),
|
||||
name: String(t.attributes?.name || '').trim(),
|
||||
timeType: String(t.attributes?.time_type || 'service'),
|
||||
startsAt: Date.parse(t.attributes?.starts_at || '') || 0,
|
||||
endsAt: Date.parse(t.attributes?.ends_at || '') || 0,
|
||||
}))
|
||||
.filter((t) => t.startsAt)
|
||||
.sort((x, y) => x.startsAt - y.startsAt);
|
||||
}
|
||||
|
||||
async function readItems(planId) {
|
||||
const target = `${planPath(planId)}/items`;
|
||||
try {
|
||||
return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
|
||||
} catch (err) {
|
||||
// An account/API that refuses one of the includes still has items.
|
||||
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
|
||||
log(`items include rejected (${err.message}) — reading items without includes`);
|
||||
return shapeItems(await pco.getAll(target));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function readPlan() {
|
||||
if (!pco.configured || !serviceTypeId) return;
|
||||
let planRec = null;
|
||||
if (pinnedPlanId) {
|
||||
planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null;
|
||||
} else {
|
||||
const chosen = await choosePlanId(serviceTypeId);
|
||||
// Re-read the chosen plan by id: the list endpoint's attributes are the
|
||||
// same, but this keeps one code path and picks up edits immediately.
|
||||
planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null;
|
||||
}
|
||||
if (!state.serviceType.name) {
|
||||
try {
|
||||
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`);
|
||||
state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') };
|
||||
} catch (err) {
|
||||
if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (!planRec) {
|
||||
state.plan = null;
|
||||
state.items = [];
|
||||
state.reachable = true;
|
||||
state.lastError = '';
|
||||
state.lastFetched = Date.now();
|
||||
return;
|
||||
}
|
||||
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
|
||||
const plan = shapePlan(planRec, serviceTypeId, planTimes);
|
||||
const service = pickServiceTime(planTimes, Date.now());
|
||||
if (service) {
|
||||
plan.serviceStartsAt = service.startsAt;
|
||||
plan.serviceEndsAt = service.endsAt;
|
||||
plan.serviceName = service.name;
|
||||
}
|
||||
scheduleItems(items, plan.serviceStartsAt || plan.sortDate);
|
||||
if (state.plan?.id !== plan.id) {
|
||||
if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title})`);
|
||||
else log(`following plan ${plan.id}: ${plan.dates} ${plan.title || ''}`.trim());
|
||||
resetLiveTracking();
|
||||
}
|
||||
state.plan = plan;
|
||||
state.items = items;
|
||||
state.reachable = true;
|
||||
state.lastError = '';
|
||||
state.lastFetched = Date.now();
|
||||
}
|
||||
|
||||
function schedulePoll(ms) {
|
||||
if (stopped) return;
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = setTimeout(poll, ms);
|
||||
pollTimer.unref?.();
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (stopped) return;
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
let delay = pollMs;
|
||||
try {
|
||||
await readPlan();
|
||||
} catch (err) {
|
||||
state.reachable = false;
|
||||
state.lastError = err?.message || String(err);
|
||||
delay = err?.retryAfter ? (err.retryAfter + 1) * 1000 : RETRY_MS;
|
||||
log(`plan read failed: ${state.lastError}`);
|
||||
}
|
||||
broadcast();
|
||||
schedulePoll(delay);
|
||||
})();
|
||||
try {
|
||||
await inFlight;
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* — phase 2: follow the ProPresenter Now/Next feed over loopback — */
|
||||
|
||||
let nnRequest = null;
|
||||
let nnRetryTimer = null;
|
||||
let lastLiveName = null;
|
||||
|
||||
function resetLiveTracking() {
|
||||
const keep = state.live;
|
||||
state.live = {
|
||||
...clearedLive(),
|
||||
following: keep.following,
|
||||
available: keep.available,
|
||||
reason: keep.reason,
|
||||
ppEnabled: keep.ppEnabled,
|
||||
ppReachable: keep.ppReachable,
|
||||
activeName: keep.activeName,
|
||||
};
|
||||
lastLiveName = null;
|
||||
}
|
||||
|
||||
/** Best plan item for a live presentation name, preferring the ones ahead of us. */
|
||||
function matchItem(name) {
|
||||
const items = state.items.filter((it) => it.itemType !== 'header');
|
||||
if (!items.length) return null;
|
||||
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId);
|
||||
let best = null;
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i];
|
||||
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
|
||||
if (score < MATCH_THRESHOLD) continue;
|
||||
// Distance ahead of the current item (wrapping so items behind rank last).
|
||||
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
|
||||
if (
|
||||
!best
|
||||
|| score > best.score + 0.001
|
||||
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
|
||||
) {
|
||||
best = { item, score, dist };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function applyLiveView(view) {
|
||||
const live = state.live;
|
||||
live.available = true;
|
||||
live.reason = '';
|
||||
live.ppEnabled = Boolean(view?.enabled);
|
||||
live.ppReachable = Boolean(view?.reachable);
|
||||
const activeItem = Array.isArray(view?.items) && view.activeIndex >= 0 ? view.items[view.activeIndex] : null;
|
||||
const name = String(activeItem?.name || view?.activePresentationName || '').trim();
|
||||
live.activeName = name;
|
||||
if (!name) {
|
||||
// Cleared output between items is normal — the current item stays current.
|
||||
lastLiveName = '';
|
||||
broadcast();
|
||||
return;
|
||||
}
|
||||
if (name === lastLiveName) return;
|
||||
lastLiveName = name;
|
||||
const hit = matchItem(name);
|
||||
live.matched = Boolean(hit);
|
||||
if (hit) {
|
||||
live.currentScore = hit.score;
|
||||
if (hit.item.id !== live.currentItemId) startItem(hit.item.id);
|
||||
}
|
||||
broadcast();
|
||||
}
|
||||
|
||||
function startItem(itemId) {
|
||||
const live = state.live;
|
||||
const now = Date.now();
|
||||
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
|
||||
live.history[live.currentItemId].endedAt = now;
|
||||
}
|
||||
live.currentItemId = itemId;
|
||||
live.history[itemId] = { startedAt: now, endedAt: 0 };
|
||||
if (!live.serviceStartedAt) live.serviceStartedAt = now;
|
||||
const item = state.items.find((it) => it.id === itemId);
|
||||
log(`live item → ${item?.title || itemId}`);
|
||||
}
|
||||
|
||||
function nowNextUnavailable(reason, retryMs) {
|
||||
const live = state.live;
|
||||
if (live.available || live.reason !== reason) {
|
||||
live.available = false;
|
||||
live.reason = reason;
|
||||
live.ppReachable = false;
|
||||
broadcast();
|
||||
}
|
||||
if (stopped) return;
|
||||
clearTimeout(nnRetryTimer);
|
||||
nnRetryTimer = setTimeout(connectNowNext, retryMs);
|
||||
nnRetryTimer.unref?.();
|
||||
}
|
||||
|
||||
function connectNowNext() {
|
||||
if (stopped || !follow) return;
|
||||
const port = shellPort();
|
||||
const req = http.get(
|
||||
{ host: '127.0.0.1', port, path: `/api/modules/${NOW_NEXT_ID}/stream`, headers: { Accept: 'text/event-stream' } },
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
const why = res.statusCode === 404
|
||||
? 'ProPresenter module is disabled or not installed'
|
||||
: `ProPresenter module feed answered ${res.statusCode}`;
|
||||
return nowNextUnavailable(why, NOW_NEXT_MISSING_RETRY_MS);
|
||||
}
|
||||
res.setEncoding('utf8');
|
||||
let buffer = '';
|
||||
res.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
||||
const block = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
let event = 'message';
|
||||
let data = '';
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
}
|
||||
if (event !== 'state' || !data) continue;
|
||||
try {
|
||||
applyLiveView(JSON.parse(data));
|
||||
} catch { /* a bad frame is not fatal */ }
|
||||
}
|
||||
});
|
||||
res.on('end', () => nowNextUnavailable('ProPresenter module feed ended — reconnecting', NOW_NEXT_RETRY_MS));
|
||||
res.on('error', () => nowNextUnavailable('ProPresenter module feed dropped — reconnecting', NOW_NEXT_RETRY_MS));
|
||||
}
|
||||
);
|
||||
req.on('error', (err) => nowNextUnavailable(`ProdDash loopback failed: ${err.message}`, NOW_NEXT_RETRY_MS));
|
||||
req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
|
||||
nnRequest = req;
|
||||
}
|
||||
|
||||
/* — start — */
|
||||
|
||||
if (!pco.configured) {
|
||||
state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin';
|
||||
log(state.lastError);
|
||||
} else if (!serviceTypeId) {
|
||||
state.lastError = 'No service type selected — open /modules/pco-plan/setup.html';
|
||||
log(state.lastError);
|
||||
} else {
|
||||
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
|
||||
schedulePoll(0);
|
||||
}
|
||||
if (follow) {
|
||||
state.live.reason = 'Connecting to the ProPresenter module feed';
|
||||
connectNowNext();
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
current = {
|
||||
streams,
|
||||
pco,
|
||||
getState: () => state,
|
||||
refresh() {
|
||||
clearTimeout(pollTimer);
|
||||
return poll();
|
||||
},
|
||||
choosePlanId,
|
||||
};
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(heartbeat);
|
||||
clearTimeout(pollTimer);
|
||||
clearTimeout(nnRetryTimer);
|
||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
streams.clear();
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
|
||||
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — open /modules/pco-plan/setup.html' };
|
||||
if (!state.reachable) {
|
||||
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
|
||||
}
|
||||
const name = state.serviceType.name || serviceTypeId;
|
||||
const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found';
|
||||
const live = follow ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
|
||||
return { status: 'ok', message: `Following ${name}${plan}${live}` };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
routes({ log }) {
|
||||
const json = (res, status, body) => {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
const state = current ? current.getState() : clearedState();
|
||||
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
const { streams } = current;
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
streams.add(res);
|
||||
req.on('close', () => streams.delete(res));
|
||||
},
|
||||
|
||||
'POST /refresh': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
current.refresh().then(
|
||||
() => json(res, 200, { ok: true, state: current?.getState() }),
|
||||
(err) => json(res, 502, { error: err?.message || String(err) })
|
||||
);
|
||||
},
|
||||
|
||||
/** Folders + service types as flat lists; the setup page builds the tree. */
|
||||
'GET /browse': async (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const { pco } = current;
|
||||
if (!pco.configured) {
|
||||
return json(res, 400, { error: 'No Planning Center credentials — add them in /admin first.', unconfigured: true });
|
||||
}
|
||||
try {
|
||||
const [folderBody, stBody] = await Promise.all([
|
||||
pco.getAll(`${SERVICES}/folders`),
|
||||
pco.getAll(`${SERVICES}/service_types`),
|
||||
]);
|
||||
const folders = new Map();
|
||||
for (const f of folderBody.data || []) {
|
||||
folders.set(String(f.id), {
|
||||
id: String(f.id),
|
||||
name: String(f.attributes?.name || 'Folder'),
|
||||
parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '',
|
||||
});
|
||||
}
|
||||
const serviceTypes = (stBody.data || []).map((st) => ({
|
||||
id: String(st.id),
|
||||
name: String(st.attributes?.name || 'Service type'),
|
||||
frequency: String(st.attributes?.frequency || ''),
|
||||
parentId: st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '',
|
||||
}));
|
||||
// Fill in any folder the list did not include (some accounts only
|
||||
// list root folders) by fetching the missing parents individually.
|
||||
const missing = new Set();
|
||||
for (const st of serviceTypes) if (st.parentId && !folders.has(st.parentId)) missing.add(st.parentId);
|
||||
for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) missing.add(f.parentId);
|
||||
let guard = 0;
|
||||
while (missing.size && guard < 50) {
|
||||
const id = missing.values().next().value;
|
||||
missing.delete(id);
|
||||
guard += 1;
|
||||
try {
|
||||
const one = (await pco.get(`${SERVICES}/folders/${encodeURIComponent(id)}`))?.data;
|
||||
const parentId = one?.relationships?.parent?.data?.id ? String(one.relationships.parent.data.id) : '';
|
||||
folders.set(id, { id, name: String(one?.attributes?.name || 'Folder'), parentId });
|
||||
if (parentId && !folders.has(parentId)) missing.add(parentId);
|
||||
} catch {
|
||||
folders.set(id, { id, name: `Folder ${id}`, parentId: '' });
|
||||
}
|
||||
}
|
||||
const state = current.getState();
|
||||
json(res, 200, {
|
||||
folders: Array.from(folders.values()),
|
||||
serviceTypes,
|
||||
selected: state.serviceType.id,
|
||||
selectedName: state.serviceType.name,
|
||||
apiBase: API_BASE,
|
||||
method: pco.method,
|
||||
});
|
||||
} catch (err) {
|
||||
log(`browse failed: ${err?.message || err}`);
|
||||
json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
/** Which plan would be followed for a service type — shown before saving. */
|
||||
'GET /preview': async (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Module not running.' });
|
||||
const params = new URLSearchParams(req.search || '');
|
||||
const stId = String(params.get('serviceTypeId') || '').trim();
|
||||
if (!stId) return json(res, 400, { error: 'serviceTypeId is required.' });
|
||||
try {
|
||||
const rec = await current.choosePlanId(stId);
|
||||
if (!rec) return json(res, 200, { plan: null });
|
||||
const a = rec.attributes || {};
|
||||
json(res, 200, {
|
||||
plan: {
|
||||
id: String(rec.id),
|
||||
title: String(a.title || ''),
|
||||
seriesTitle: String(a.series_title || ''),
|
||||
dates: String(a.dates || ''),
|
||||
itemsCount: Number(a.items_count) || 0,
|
||||
totalLength: Number(a.total_length) || 0,
|
||||
sortDate: a.sort_date || '',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,372 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PCO Plan — Planning Center setup</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<style>
|
||||
/* The module's own setup page: browse Planning Center folders and pick the
|
||||
service type to follow. Saves through the shell's admin API, so the
|
||||
choice lands in config/modules.json like any other admin setting. */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px 16px 48px;
|
||||
background: var(--bg, #0b0e12);
|
||||
color: var(--text, #e6edf3);
|
||||
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
.ps-page { max-width: 760px; margin: 0 auto; }
|
||||
.ps-top { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.ps-top h1 { margin: 0; font-size: 20px; }
|
||||
.ps-top a { color: var(--accent, #2ee59a); text-decoration: none; font-weight: 600; }
|
||||
.ps-lead { color: var(--muted, #8b98a5); margin: 6px 0 18px; }
|
||||
.ps-card {
|
||||
background: var(--panel, #131920);
|
||||
border: 1px solid var(--border, #232b35);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.ps-card h2 { margin: 0 0 10px; font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted, #8b98a5); }
|
||||
.ps-current { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.ps-current strong { font-size: 15px; }
|
||||
.ps-crumbs { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 13px; }
|
||||
.ps-crumbs button {
|
||||
background: none; border: none; color: var(--accent, #2ee59a); font: inherit; font-weight: 600; cursor: pointer; padding: 2px 4px; border-radius: 4px;
|
||||
}
|
||||
.ps-crumbs button:hover { background: color-mix(in srgb, var(--accent, #2ee59a) 14%, transparent); }
|
||||
.ps-crumbs button[disabled] { color: var(--text, #e6edf3); cursor: default; background: none; }
|
||||
.ps-crumbs .sep { color: var(--muted, #8b98a5); }
|
||||
.ps-rows { display: flex; flex-direction: column; gap: 4px; }
|
||||
.ps-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 10px; border-radius: 8px; border: 1px solid transparent;
|
||||
}
|
||||
.ps-row:hover { background: color-mix(in srgb, var(--text, #e6edf3) 4%, transparent); }
|
||||
.ps-row.is-folder { cursor: pointer; }
|
||||
.ps-row.is-selected { border-color: color-mix(in srgb, var(--accent, #2ee59a) 50%, transparent); background: color-mix(in srgb, var(--accent, #2ee59a) 10%, transparent); }
|
||||
.ps-icon { flex: none; width: 22px; text-align: center; color: var(--muted, #8b98a5); }
|
||||
.ps-name { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ps-meta { color: var(--muted, #8b98a5); font-size: 12px; white-space: nowrap; }
|
||||
.ps-btn {
|
||||
background: none; border: 1px solid var(--border, #232b35); color: var(--text, #e6edf3);
|
||||
font: inherit; font-weight: 600; font-size: 13px; padding: 5px 12px; border-radius: 999px; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.ps-btn:hover { border-color: var(--accent, #2ee59a); color: var(--accent, #2ee59a); }
|
||||
.ps-btn.primary { background: var(--accent, #2ee59a); border-color: var(--accent, #2ee59a); color: var(--bg, #0b0e12); }
|
||||
.ps-btn.primary:hover { color: var(--bg, #0b0e12); filter: brightness(1.08); }
|
||||
.ps-btn[disabled] { opacity: 0.5; cursor: default; }
|
||||
.ps-preview { margin-top: 6px; padding: 8px 10px 8px 42px; color: var(--muted, #8b98a5); font-size: 13px; }
|
||||
.ps-preview strong { color: var(--text, #e6edf3); }
|
||||
.ps-status { margin-top: 10px; font-size: 13px; color: var(--muted, #8b98a5); min-height: 1.3em; }
|
||||
.ps-status.ok { color: var(--accent, #2ee59a); }
|
||||
.ps-status.error { color: var(--danger, #f4433c); }
|
||||
.ps-empty { color: var(--muted, #8b98a5); padding: 10px; }
|
||||
.ps-login { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 8px; }
|
||||
.ps-login input {
|
||||
background: var(--bg, #0b0e12); border: 1px solid var(--border, #232b35); color: var(--text, #e6edf3);
|
||||
font: inherit; padding: 6px 10px; border-radius: 8px; width: 200px;
|
||||
}
|
||||
.ps-hint { color: var(--muted, #8b98a5); font-size: 12px; margin-top: 8px; }
|
||||
.ps-hint code { background: color-mix(in srgb, var(--text, #e6edf3) 8%, transparent); padding: 1px 5px; border-radius: 4px; }
|
||||
[hidden] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ps-page">
|
||||
<div class="ps-top">
|
||||
<h1>PCO Plan · Planning Center setup</h1>
|
||||
<a href="/admin">← Admin</a>
|
||||
<a href="/">Dashboard</a>
|
||||
</div>
|
||||
<p class="ps-lead">Browse your Planning Center Services folders and choose the service type whose plans the PCO Plan tiles should follow.</p>
|
||||
|
||||
<section class="ps-card">
|
||||
<h2>Currently following</h2>
|
||||
<div class="ps-current" id="current">Loading…</div>
|
||||
<div class="ps-status" id="current-status"></div>
|
||||
</section>
|
||||
|
||||
<section class="ps-card">
|
||||
<h2>Service types</h2>
|
||||
<div class="ps-crumbs" id="crumbs"></div>
|
||||
<div class="ps-rows" id="rows"><div class="ps-empty">Loading folders from Planning Center…</div></div>
|
||||
<div class="ps-status" id="browse-status"></div>
|
||||
<div class="ps-login" id="login" hidden>
|
||||
<span>Admin passcode:</span>
|
||||
<input type="password" id="passcode" autocomplete="current-password">
|
||||
<button class="ps-btn primary" id="login-btn">Unlock</button>
|
||||
<span class="ps-status" id="login-status"></span>
|
||||
</div>
|
||||
<div class="ps-hint">Credentials (Application ID + Secret from <code>api.planningcenteronline.com</code>, or an OAuth token) are entered under PCO Plan in <a href="/admin" style="color:var(--accent)">Admin</a>. Saving here re-initialises the module; open tiles pick up the new plan on their own.</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const MODULE = 'pco-plan';
|
||||
const els = {
|
||||
current: document.getElementById('current'),
|
||||
currentStatus: document.getElementById('current-status'),
|
||||
crumbs: document.getElementById('crumbs'),
|
||||
rows: document.getElementById('rows'),
|
||||
browseStatus: document.getElementById('browse-status'),
|
||||
login: document.getElementById('login'),
|
||||
passcode: document.getElementById('passcode'),
|
||||
loginBtn: document.getElementById('login-btn'),
|
||||
loginStatus: document.getElementById('login-status'),
|
||||
};
|
||||
|
||||
let tree = null; // { folders: Map, serviceTypes: [], selected }
|
||||
let path = []; // folder ids from root to the open folder
|
||||
let pendingSave = null; // service type waiting for the passcode
|
||||
|
||||
async function api(url, opts = {}) {
|
||||
const res = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) } });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(body.error || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
err.authRequired = Boolean(body.authRequired);
|
||||
err.unconfigured = Boolean(body.unconfigured);
|
||||
throw err;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function setStatus(el, text, kind) {
|
||||
el.textContent = text || '';
|
||||
el.className = 'ps-status' + (kind ? ' ' + kind : '');
|
||||
}
|
||||
|
||||
/* — current selection — */
|
||||
|
||||
async function loadCurrent() {
|
||||
try {
|
||||
const { state } = await api(`/api/modules/${MODULE}/state`);
|
||||
els.current.innerHTML = '';
|
||||
if (!state.configured) {
|
||||
els.current.textContent = 'No Planning Center credentials yet — add them in Admin first.';
|
||||
return;
|
||||
}
|
||||
if (!state.serviceTypeSet) {
|
||||
els.current.textContent = 'No service type selected.';
|
||||
return;
|
||||
}
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = state.serviceType.name || `Service type ${state.serviceType.id}`;
|
||||
els.current.appendChild(strong);
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'ps-meta';
|
||||
if (state.plan) {
|
||||
meta.textContent = `Plan: ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''} · ${state.items.length} items`;
|
||||
} else if (state.reachable) {
|
||||
meta.textContent = 'No upcoming or recent plan found';
|
||||
} else {
|
||||
meta.textContent = state.lastError || 'Reading…';
|
||||
}
|
||||
els.current.appendChild(meta);
|
||||
setStatus(els.currentStatus, state.reachable ? '' : state.lastError, state.reachable ? '' : 'error');
|
||||
} catch (e) {
|
||||
els.current.textContent = `Module not running: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/* — browsing — */
|
||||
|
||||
async function loadTree() {
|
||||
setStatus(els.browseStatus, 'Loading…');
|
||||
try {
|
||||
const body = await api(`/api/modules/${MODULE}/browse`);
|
||||
const folders = new Map(body.folders.map((f) => [f.id, f]));
|
||||
// A parent that still is not known is treated as root.
|
||||
for (const f of folders.values()) if (f.parentId && !folders.has(f.parentId)) f.parentId = '';
|
||||
for (const st of body.serviceTypes) if (st.parentId && !folders.has(st.parentId)) st.parentId = '';
|
||||
tree = { folders, serviceTypes: body.serviceTypes, selected: body.selected };
|
||||
// Open the folder that holds the current selection.
|
||||
path = [];
|
||||
const sel = body.serviceTypes.find((st) => st.id === body.selected);
|
||||
if (sel) {
|
||||
let pid = sel.parentId;
|
||||
while (pid && folders.has(pid)) { path.unshift(pid); pid = folders.get(pid).parentId; }
|
||||
}
|
||||
setStatus(els.browseStatus, `${body.serviceTypes.length} service types in ${folders.size} folders (${body.method === 'oauth' ? 'OAuth token' : 'personal access token'})`);
|
||||
renderTree();
|
||||
} catch (e) {
|
||||
els.rows.innerHTML = '';
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'ps-empty';
|
||||
msg.textContent = e.unconfigured
|
||||
? 'Add Planning Center credentials for PCO Plan in Admin, then reload this page.'
|
||||
: (e.status === 401 ? 'Planning Center rejected the credentials — check the Application ID and Secret in Admin.' : e.message);
|
||||
els.rows.appendChild(msg);
|
||||
setStatus(els.browseStatus, '', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCrumbs() {
|
||||
els.crumbs.innerHTML = '';
|
||||
const add = (label, depth) => {
|
||||
if (els.crumbs.children.length) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'sep';
|
||||
sep.textContent = '›';
|
||||
els.crumbs.appendChild(sep);
|
||||
}
|
||||
const b = document.createElement('button');
|
||||
b.textContent = label;
|
||||
b.disabled = depth === path.length;
|
||||
b.addEventListener('click', () => { path = path.slice(0, depth); renderTree(); });
|
||||
els.crumbs.appendChild(b);
|
||||
};
|
||||
add('All folders', 0);
|
||||
path.forEach((id, i) => add(tree.folders.get(id)?.name || 'Folder', i + 1));
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
if (!tree) return;
|
||||
renderCrumbs();
|
||||
els.rows.innerHTML = '';
|
||||
const here = path.length ? path[path.length - 1] : '';
|
||||
const folders = Array.from(tree.folders.values()).filter((f) => (f.parentId || '') === here)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const types = tree.serviceTypes.filter((st) => (st.parentId || '') === here)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const f of folders) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ps-row is-folder';
|
||||
row.innerHTML = '<span class="ps-icon">📁</span>';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ps-name';
|
||||
name.textContent = f.name;
|
||||
const count = document.createElement('span');
|
||||
count.className = 'ps-meta';
|
||||
const n = countTypes(f.id);
|
||||
count.textContent = n === 1 ? '1 service type' : `${n} service types`;
|
||||
row.append(name, count);
|
||||
row.addEventListener('click', () => { path = [...path, f.id]; renderTree(); });
|
||||
els.rows.appendChild(row);
|
||||
}
|
||||
|
||||
for (const st of types) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ps-row' + (st.id === tree.selected ? ' is-selected' : '');
|
||||
row.innerHTML = '<span class="ps-icon">▤</span>';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ps-name';
|
||||
name.textContent = st.name;
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'ps-meta';
|
||||
meta.textContent = st.frequency ? st.frequency : '';
|
||||
const preview = document.createElement('button');
|
||||
preview.className = 'ps-btn';
|
||||
preview.textContent = 'Preview plan';
|
||||
const pick = document.createElement('button');
|
||||
pick.className = 'ps-btn primary';
|
||||
pick.textContent = st.id === tree.selected ? 'Selected' : 'Use this';
|
||||
pick.disabled = st.id === tree.selected;
|
||||
row.append(name, meta, preview, pick);
|
||||
els.rows.appendChild(row);
|
||||
|
||||
const previewEl = document.createElement('div');
|
||||
previewEl.className = 'ps-preview';
|
||||
previewEl.hidden = true;
|
||||
els.rows.appendChild(previewEl);
|
||||
|
||||
preview.addEventListener('click', async () => {
|
||||
previewEl.hidden = false;
|
||||
previewEl.textContent = 'Looking up the plan…';
|
||||
try {
|
||||
const { plan } = await api(`/api/modules/${MODULE}/preview?serviceTypeId=${encodeURIComponent(st.id)}`);
|
||||
if (!plan) {
|
||||
previewEl.textContent = 'No upcoming or recent plan for this service type.';
|
||||
return;
|
||||
}
|
||||
previewEl.innerHTML = '';
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = `${plan.dates}${plan.title ? ` — ${plan.title}` : ''}`;
|
||||
previewEl.appendChild(strong);
|
||||
previewEl.appendChild(document.createTextNode(
|
||||
`${plan.seriesTitle ? ` · ${plan.seriesTitle}` : ''} · ${plan.itemsCount} items · ${Math.round(plan.totalLength / 60)} min`
|
||||
));
|
||||
} catch (e) {
|
||||
previewEl.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
pick.addEventListener('click', () => save(st));
|
||||
}
|
||||
|
||||
if (!folders.length && !types.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'ps-empty';
|
||||
empty.textContent = 'This folder is empty.';
|
||||
els.rows.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
function countTypes(folderId) {
|
||||
let n = 0;
|
||||
const stack = [folderId];
|
||||
const seen = new Set();
|
||||
while (stack.length) {
|
||||
const id = stack.pop();
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
n += tree.serviceTypes.filter((st) => st.parentId === id).length;
|
||||
for (const f of tree.folders.values()) if (f.parentId === id) stack.push(f.id);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* — saving through the shell's admin API — */
|
||||
|
||||
async function save(st) {
|
||||
setStatus(els.browseStatus, `Saving ${st.name}…`);
|
||||
try {
|
||||
await api(`/api/admin/modules/${MODULE}/config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config: { serviceTypeId: st.id } }),
|
||||
});
|
||||
tree.selected = st.id;
|
||||
pendingSave = null;
|
||||
els.login.hidden = true;
|
||||
setStatus(els.browseStatus, `Now following ${st.name}. The module re-initialised; tiles update on their own.`, 'ok');
|
||||
renderTree();
|
||||
// The module re-reads the plan right after re-init; give it a moment.
|
||||
setTimeout(loadCurrent, 1500);
|
||||
setTimeout(loadCurrent, 5000);
|
||||
} catch (e) {
|
||||
if (e.authRequired) {
|
||||
pendingSave = st;
|
||||
els.login.hidden = false;
|
||||
els.passcode.focus();
|
||||
setStatus(els.browseStatus, 'Enter the admin passcode to save.', 'error');
|
||||
} else {
|
||||
setStatus(els.browseStatus, e.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
setStatus(els.loginStatus, '');
|
||||
try {
|
||||
await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: els.passcode.value }) });
|
||||
els.passcode.value = '';
|
||||
els.login.hidden = true;
|
||||
if (pendingSave) save(pendingSave);
|
||||
} catch (e) {
|
||||
setStatus(els.loginStatus, e.message, 'error');
|
||||
}
|
||||
}
|
||||
els.loginBtn.addEventListener('click', login);
|
||||
els.passcode.addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
|
||||
|
||||
loadCurrent();
|
||||
loadTree();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,312 @@
|
||||
/* PCO Plan module — scoped to its tiles, shell variables only.
|
||||
Everything scales from --pp-size (set from the tile's text-size setting). */
|
||||
|
||||
[data-module="pco-plan"] .pp-wrap {
|
||||
--pp-size: 14px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--pp-size);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── plan header ── */
|
||||
|
||||
[data-module="pco-plan"] .pp-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
padding: 0.6em 0.85em 0.5em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
}
|
||||
[data-module="pco-plan"] .pp-head[hidden] { display: none; }
|
||||
|
||||
[data-module="pco-plan"] .pp-plan { flex: 1; min-width: 0; }
|
||||
|
||||
[data-module="pco-plan"] .pp-plan-title {
|
||||
font-size: 1.05em;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-plan-sub {
|
||||
margin-top: 0.15em;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-clock {
|
||||
flex: none;
|
||||
text-align: right;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-clock-label {
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-clock-value {
|
||||
font-size: 1.25em;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-clock-value:empty,
|
||||
[data-module="pco-plan"] .pp-clock-label:empty { display: none; }
|
||||
[data-module="pco-plan"] .pp-clock-value.is-behind { color: var(--warn); }
|
||||
[data-module="pco-plan"] .pp-clock-value.is-ahead { color: var(--accent); }
|
||||
|
||||
/* ── list ── */
|
||||
|
||||
[data-module="pco-plan"] .pp-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0.35em 0.5em 0.6em;
|
||||
overscroll-behavior: contain;
|
||||
scroll-padding: 2em;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6em;
|
||||
padding: 0.45em 0.5em;
|
||||
border-radius: 0.5em;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.2s ease, opacity 0.3s ease;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-row + .pp-row { margin-top: 0.15em; }
|
||||
[data-module="pco-plan"] .pp-row:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
|
||||
|
||||
[data-module="pco-plan"] .pp-row.is-header {
|
||||
margin-top: 0.6em;
|
||||
padding-top: 0.5em;
|
||||
padding-bottom: 0.15em;
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0; border-top: none; }
|
||||
[data-module="pco-plan"] .pp-row.is-header .pp-title {
|
||||
font-size: 0.78em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-row.is-pre .pp-title,
|
||||
[data-module="pco-plan"] .pp-row.is-post .pp-title { color: color-mix(in srgb, var(--text) 75%, var(--muted)); }
|
||||
|
||||
[data-module="pco-plan"] .pp-row.is-done { opacity: 0.45; }
|
||||
[data-module="pco-plan"] .pp-row.is-done:hover { opacity: 0.8; }
|
||||
|
||||
[data-module="pco-plan"] .pp-row.is-current {
|
||||
border-left-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-col-time {
|
||||
flex: none;
|
||||
width: 4.6em;
|
||||
padding-top: 0.1em;
|
||||
font-size: 0.85em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-col-time[hidden] { display: none; }
|
||||
[data-module="pco-plan"] .pp-row.is-current .pp-col-time { color: var(--accent); font-weight: 700; }
|
||||
|
||||
[data-module="pco-plan"] .pp-col-main { flex: 1; min-width: 0; }
|
||||
|
||||
[data-module="pco-plan"] .pp-title-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2em 0.55em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-title {
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-now-tag {
|
||||
display: none;
|
||||
padding: 0.05em 0.45em;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-size: 0.65em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-row.is-current .pp-now-tag { display: inline-block; }
|
||||
|
||||
[data-module="pco-plan"] .pp-badge,
|
||||
[data-module="pco-plan"] .pp-key,
|
||||
[data-module="pco-plan"] .pp-arr {
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.1em 0.45em;
|
||||
border-radius: 0.35em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-badge {
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
[data-module="pco-plan"] .pp-badge.is-song { color: color-mix(in srgb, var(--accent) 80%, var(--text)); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
|
||||
[data-module="pco-plan"] .pp-badge.is-media { color: color-mix(in srgb, var(--warn) 85%, var(--text)); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
|
||||
[data-module="pco-plan"] .pp-key {
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
color: var(--text);
|
||||
text-transform: none;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-arr { color: var(--muted); text-transform: none; letter-spacing: 0; font-weight: 600; }
|
||||
|
||||
[data-module="pco-plan"] .pp-desc {
|
||||
margin-top: 0.15em;
|
||||
font-size: 0.86em;
|
||||
line-height: 1.35;
|
||||
color: color-mix(in srgb, var(--text) 80%, var(--muted));
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-notes {
|
||||
margin-top: 0.3em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2em;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-note {
|
||||
font-size: 0.82em;
|
||||
line-height: 1.35;
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 0.35em;
|
||||
background: color-mix(in srgb, var(--warn) 9%, transparent);
|
||||
border-left: 2px solid color-mix(in srgb, var(--warn) 60%, transparent);
|
||||
color: var(--text);
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-note-cat {
|
||||
display: inline-block;
|
||||
margin-right: 0.5em;
|
||||
font-size: 0.8em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-col-len {
|
||||
flex: none;
|
||||
min-width: 3.2em;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-col-len[hidden] { display: none; }
|
||||
[data-module="pco-plan"] .pp-len {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
padding-top: 0.1em;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-elapsed {
|
||||
font-size: 0.9em;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
opacity: 0.8;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-elapsed:empty { display: none; }
|
||||
[data-module="pco-plan"] .pp-elapsed.is-running { color: var(--accent); opacity: 1; }
|
||||
[data-module="pco-plan"] .pp-elapsed.is-over { color: var(--warn); opacity: 1; }
|
||||
[data-module="pco-plan"] .pp-elapsed.is-over-hard { color: var(--danger); opacity: 1; }
|
||||
|
||||
/* ── empty / degraded states ── */
|
||||
|
||||
[data-module="pco-plan"] .pp-empty {
|
||||
padding: 12% 1.2em 1em;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="pco-plan"] .pp-empty-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
[data-module="pco-plan"] .pp-empty-hint {
|
||||
margin-top: 0.4em;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-empty-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.9em;
|
||||
padding: 0.35em 0.9em;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
|
||||
[data-module="pco-plan"] .pp-wrap.is-offline .pp-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
|
||||
[data-module="pco-plan"] .pp-wrap.is-offline .pp-list { opacity: 0.6; }
|
||||
|
||||
/* ── footer: ProPresenter match line ── */
|
||||
|
||||
[data-module="pco-plan"] .pp-foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
padding: 0.4em 0.85em;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="pco-plan"] .pp-foot[hidden] { display: none; }
|
||||
[data-module="pco-plan"] .pp-foot::before {
|
||||
content: '';
|
||||
flex: none;
|
||||
width: 0.55em;
|
||||
height: 0.55em;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
}
|
||||
[data-module="pco-plan"] .pp-foot.is-live { color: var(--text); }
|
||||
[data-module="pco-plan"] .pp-foot.is-live::before { background: var(--accent); box-shadow: 0 0 0 0.2em color-mix(in srgb, var(--accent) 18%, transparent); }
|
||||
[data-module="pco-plan"] .pp-foot.is-unmatched { color: var(--warn); }
|
||||
[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); }
|
||||
[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); }
|
||||
[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); }
|
||||
Reference in New Issue
Block a user