pco-plan: everything configured in Admin; key, section and top-bar restyle

- Drops the separate setup page: the service type is a select grouped by
  Planning Center folder path (GET /service-types), the pinned plan a
  select of upcoming/recent plans that follows the chosen service type
  (GET /plans?serviceTypeId=), credentials sit in a group that folds once
  saved.
- Show/hide moves from per-tile settings to a server-wide checklist in
  Admin (three columns, folded by default): top bar, series, part, service
  type, date, service time, total length, running clock, countdown,
  headers, item numbers, start times, length, type badge, key, arrangement,
  author/CCLI, description, notes, pre/post-service items, now indicator,
  item runtime, dim completed, ProPresenter line. Tiles keep only text
  size and auto-scroll.
- Top bar text is a template with placeholders ({series} • {part} |
  {serviceType} • {date} by default; • separates droppable segments, |
  starts the second line).
- Song key: larger, admin-chosen color (blue by default), always right
  after the title and before the type badge. Section headers are
  full-width accent bands. Optional background tint per item type.
- tools/pco-mock.js plan titles are part numbers, as in a real series.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-10 15:31:12 -04:00
co-authored by Claude Fable 5.1
parent 106ce0c1ce
commit 20dc2deb2b
6 changed files with 408 additions and 214 deletions
+9 -7
View File
@@ -137,13 +137,15 @@ ignored from then on.
ProPresenter endpoint (host : port), password. ProPresenter endpoint (host : port), password.
- **PCO Plan** — the order of service from Planning Center Services: every - **PCO Plan** — the order of service from Planning Center Services: every
plan item with its scheduled start time, length, type, song key, plan item with its scheduled start time, length, type, song key,
description and item notes (each detail is a per-tile show/hide setting). description and item notes. When the ProPresenter module is running it
When the ProPresenter module is running it follows that module's live follows that module's live item, highlights the matching plan item, dims
item, highlights the matching plan item, dims what's done and shows the what's done and shows the elapsed vs planned runtime and how far
elapsed vs planned runtime and how far ahead/behind the service is. Admin ahead/behind the service is. Everything is configured in `/admin`:
config: Planning Center credentials (Personal Access Token app ID + secret, Planning Center credentials (Personal Access Token app ID + secret, or an
or an OAuth token) and the service type — browse your Planning Center OAuth token), the service type (a list grouped by your Planning Center
folders and pick it at `/modules/pco-plan/setup.html`. folders), an optional pinned plan, the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, and a show/hide
checklist for every detail. Tiles only choose their text size.
- **Clock** — a big booth clock; also the smallest possible module and the - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
+161 -98
View File
@@ -2,10 +2,11 @@
The server part reads Planning Center and (optionally) follows the The server part reads Planning Center and (optionally) follows the
ProPresenter module's live item; this tile only renders the plan it is ProPresenter module's live item; this tile only renders the plan it is
streamed. Which details show is a per-tile choice (gear menu). */ streamed. What is shown is decided server-wide in /admin (moduleApi.config:
show/hide checklist, top-bar template, colors); the tile itself only owns
its text size and auto-scroll. */
const REFRESH_SVG = '<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 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 }) { export default function create({ root, moduleApi }) {
let state = null; let state = null;
@@ -42,7 +43,10 @@ export default function create({ root, moduleApi }) {
const listEl = root.querySelector('.pp-list'); const listEl = root.querySelector('.pp-list');
const footEl = root.querySelector('.pp-foot'); const footEl = root.querySelector('.pp-foot');
const prefs = () => moduleApi.instanceSettings; /** Admin-wide display settings (the show/hide checklist lives there). */
const cfg = () => moduleApi.config || {};
const on = (key) => cfg()[key] !== false; // unset = shown
const tilePrefs = () => moduleApi.instanceSettings;
const serverNow = () => Date.now() + skew; const serverNow = () => Date.now() + skew;
/* ── title-bar controls ─────────────────────────────────────────── */ /* ── title-bar controls ─────────────────────────────────────────── */
@@ -59,21 +63,13 @@ export default function create({ root, moduleApi }) {
}); });
function bumpTextSize(delta) { function bumpTextSize(delta) {
const size = Number(prefs().textSize) || 14; const size = Number(tilePrefs().textSize) || 14;
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) }); moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
applyPrefs(); applyPrefs();
} }
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) }); 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({ 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 ─────────────────────────────────────────────────── */ /* ── formatting ─────────────────────────────────────────────────── */
function fmtClock(ms) { function fmtClock(ms) {
@@ -103,26 +99,30 @@ export default function create({ root, moduleApi }) {
switch (itemType) { switch (itemType) {
case 'song': return 'Song'; case 'song': return 'Song';
case 'media': return 'Media'; case 'media': return 'Media';
case 'header': return '';
default: return ''; default: return '';
} }
} }
function plannedLength() {
return (state?.items || [])
.filter((it) => it.servicePosition === 'during')
.reduce((sum, it) => sum + (it.length || 0), 0);
}
/* ── which items to show ────────────────────────────────────────── */ /* ── which items to show ────────────────────────────────────────── */
function visibleItems() { function visibleItems() {
const p = prefs();
const items = Array.isArray(state?.items) ? state.items : []; const items = Array.isArray(state?.items) ? state.items : [];
return items.filter((it) => { return items.filter((it) => {
if (it.itemType === 'header' && !p.showHeaders) return false; if (it.itemType === 'header' && !on('showHeaders')) return false;
if (it.servicePosition === 'pre' && !p.showPreService) return false; if (it.servicePosition === 'pre' && !on('showPreService')) return false;
if (it.servicePosition === 'post' && !p.showPostService) return false; if (it.servicePosition === 'post' && !on('showPostService')) return false;
return true; return true;
}); });
} }
function noteFilter() { function noteFilter() {
const raw = String(prefs().noteCategories || ''); const raw = String(cfg().noteCategories || '');
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true; return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
} }
@@ -133,7 +133,7 @@ export default function create({ root, moduleApi }) {
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…'); if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
if (!state) return moduleApi.setStatus('connecting', 'Connecting…'); if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin'); if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — use the setup page'); if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
if (!state.reachable) { if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…'); return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
} }
@@ -147,22 +147,65 @@ export default function create({ root, moduleApi }) {
moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`); moduleApi.setStatus('ok', `Plan read ${new Date(state.lastFetched).toLocaleTimeString()}${extra}`);
} }
/* ── plan header ────────────────────────────────────────────────── */ /* ── top bar: admin template with placeholders ──────────────────── */
const DEFAULT_TEMPLATE = '{series} • {part} | {serviceType} • {date}';
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
function templateValues() {
const plan = state?.plan || {};
const items = state?.items || [];
const planned = plannedLength();
return {
series: on('showSeries') ? plan.seriesTitle || '' : '',
part: on('showPart') ? plan.title || '' : '',
title: on('showPart') ? plan.title || '' : '',
servicetype: on('showServiceType') ? state?.serviceType?.name || '' : '',
date: on('showDate') ? plan.dates || '' : '',
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
time: on('showServiceTime') && plan.serviceStartsAt
? `${plan.serviceName && plan.serviceName !== 'Service' ? `${plan.serviceName} ` : ''}${fmtClock(plan.serviceStartsAt)}`
: '',
length: on('showTotalLength') && planned ? fmtMinutes(planned) : '',
items: items.filter((it) => it.itemType !== 'header').length ? String(items.filter((it) => it.itemType !== 'header').length) : '',
};
}
/**
* "{series} • {part} | {serviceType} • {date}" → up to two lines. Within a
* line, • (or ·) separates segments; a segment whose placeholders all came
* out empty is dropped, so a plan with no series just reads "Wk 2".
*/
function renderTemplate(template, values) {
const lines = String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2);
return lines.map((line) => {
const segments = line.split(/\s*[•·]\s*/);
const kept = [];
for (const seg of segments) {
let sawVar = false;
let allEmpty = true;
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
sawVar = true;
const v = values[name.toLowerCase()];
if (v) allEmpty = false;
return v || '';
}).trim();
if (sawVar && allEmpty) continue;
if (text) kept.push(text);
}
return kept.join(' • ');
});
}
function renderHead() { function renderHead() {
const plan = state?.plan; const plan = state?.plan;
const show = prefs().showPlanHeader && plan; const show = on('showTopBar') && plan;
headEl.hidden = !show; headEl.hidden = !show;
if (!show) return; if (!show) return;
const title = [plan.dates, plan.title || plan.seriesTitle].filter(Boolean).join(' · '); const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
planTitleEl.textContent = title || (state.serviceType?.name || 'Plan'); planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan';
const bits = []; planSubEl.textContent = line1 ? line2 || '' : '';
if (state.serviceType?.name) bits.push(state.serviceType.name); planSubEl.hidden = !planSubEl.textContent;
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(); renderClock();
} }
@@ -174,16 +217,13 @@ export default function create({ root, moduleApi }) {
clockValueEl.className = 'pp-clock-value'; clockValueEl.className = 'pp-clock-value';
if (!plan || headEl.hidden) return; if (!plan || headEl.hidden) return;
const now = serverNow(); const now = serverNow();
if (prefs().showRuntime && live.serviceStartedAt) { if (on('showRunningClock') && live.following && live.serviceStartedAt) {
clockLabelEl.textContent = 'Running'; clockLabelEl.textContent = 'Running';
clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000); clockValueEl.textContent = fmtDur((now - live.serviceStartedAt) / 1000);
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take. // Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
const planned = state.items const done = state.items.filter((it) => live.history?.[it.id]?.endedAt);
.filter((it) => live.history?.[it.id]?.endedAt) const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
.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 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; const drift = actual - planned;
if (planned && Math.abs(drift) >= 30) { if (planned && Math.abs(drift) >= 30) {
clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`; clockLabelEl.textContent = `Running · ${drift > 0 ? 'behind' : 'ahead'} ${fmtDur(Math.abs(drift))}`;
@@ -191,12 +231,12 @@ export default function create({ root, moduleApi }) {
} }
return; return;
} }
if (plan.serviceStartsAt && plan.serviceStartsAt > now) { if (on('showCountdown') && plan.serviceStartsAt && plan.serviceStartsAt > now) {
clockLabelEl.textContent = 'Starts in'; clockLabelEl.textContent = 'Starts in';
clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000); clockValueEl.textContent = fmtDur((plan.serviceStartsAt - now) / 1000);
return; return;
} }
if (plan.serviceStartsAt) { if (on('showRunningClock') && plan.serviceStartsAt) {
clockLabelEl.textContent = 'Since start'; clockLabelEl.textContent = 'Since start';
clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000); clockValueEl.textContent = fmtDur((now - plan.serviceStartsAt) / 1000);
} }
@@ -210,15 +250,15 @@ export default function create({ root, moduleApi }) {
if (!state.configured) { if (!state.configured) {
return { return {
title: 'Planning Center not connected', title: 'Planning Center not connected',
hint: 'Add a Planning Center Application ID and Secret (Personal Access Token) for PCO Plan in Admin.', hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
link: { href: '/admin', text: 'Open Admin' }, link: { href: '/admin', text: 'Open Admin' },
}; };
} }
if (!state.serviceTypeSet) { if (!state.serviceTypeSet) {
return { return {
title: 'No service type selected', title: 'No service type selected',
hint: 'Browse your Planning Center folders and choose the service type to follow.', hint: 'Pick the service type to follow from the list under PCO Plan in Admin.',
link: { href: `/modules/${moduleApi.id}/setup.html`, text: 'Open setup' }, link: { href: '/admin', text: 'Open Admin' },
}; };
} }
if (!state.reachable && !state.plan) { if (!state.reachable && !state.plan) {
@@ -230,22 +270,32 @@ export default function create({ root, moduleApi }) {
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`, hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
}; };
} }
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings.' }; if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by the PCO Plan settings in Admin.' };
return null; return null;
} }
function buildRow(item, p, notesWanted) { function buildRow(item, number, notesWanted) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'pp-row'; row.className = 'pp-row';
row.dataset.id = item.id; row.dataset.id = item.id;
if (item.itemType === 'header') row.classList.add('is-header'); 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}`); if (item.servicePosition !== 'during') row.classList.add(`is-${item.servicePosition}`);
const isHeader = item.itemType === 'header';
const timeCol = document.createElement('div'); if (on('showItemNumbers')) {
timeCol.className = 'pp-col-time'; const numCol = document.createElement('div');
timeCol.textContent = p.showStartTimes && item.startsAt && item.itemType !== 'header' ? fmtClock(item.startsAt) : ''; numCol.className = 'pp-col-num';
timeCol.hidden = !p.showStartTimes; numCol.textContent = isHeader ? '' : String(number);
row.appendChild(timeCol); 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'); const main = document.createElement('div');
main.className = 'pp-col-main'; main.className = 'pp-col-main';
@@ -255,24 +305,27 @@ export default function create({ root, moduleApi }) {
titleEl.className = 'pp-title'; titleEl.className = 'pp-title';
titleEl.textContent = item.title || item.song?.title || '(untitled)'; titleEl.textContent = item.title || item.song?.title || '(untitled)';
titleLine.appendChild(titleEl); titleLine.appendChild(titleEl);
if (item.itemType !== 'header') { if (!isHeader) {
const nowTag = document.createElement('span'); // Order after the title: key, NOW, type badge, arrangement.
nowTag.className = 'pp-now-tag'; if (on('showKey') && item.keyName) {
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'); const key = document.createElement('span');
key.className = 'pp-key'; key.className = 'pp-key';
key.textContent = item.keyName; key.textContent = item.keyName;
titleLine.appendChild(key); titleLine.appendChild(key);
} }
if (p.showArrangement && item.arrangement) { if (on('showNowIndicator')) {
const nowTag = document.createElement('span');
nowTag.className = 'pp-now-tag';
nowTag.textContent = 'Now';
titleLine.appendChild(nowTag);
}
if (on('showItemType') && typeLabel(item.itemType)) {
const badge = document.createElement('span');
badge.className = `pp-badge is-${item.itemType}`;
badge.textContent = typeLabel(item.itemType);
titleLine.appendChild(badge);
}
if (on('showArrangement') && item.arrangement) {
const arr = document.createElement('span'); const arr = document.createElement('span');
arr.className = 'pp-arr'; arr.className = 'pp-arr';
arr.textContent = item.arrangement; arr.textContent = item.arrangement;
@@ -281,13 +334,19 @@ export default function create({ root, moduleApi }) {
} }
main.appendChild(titleLine); main.appendChild(titleLine);
if (p.showDescription && item.description) { if (!isHeader && on('showSongAuthor') && item.song && (item.song.author || item.song.ccli)) {
const author = document.createElement('div');
author.className = 'pp-author';
author.textContent = [item.song.author, item.song.ccli ? `CCLI ${item.song.ccli}` : ''].filter(Boolean).join(' · ');
main.appendChild(author);
}
if (on('showDescription') && item.description) {
const desc = document.createElement('div'); const desc = document.createElement('div');
desc.className = 'pp-desc'; desc.className = 'pp-desc';
desc.textContent = item.description; desc.textContent = item.description;
main.appendChild(desc); main.appendChild(desc);
} }
if (p.showNotes && item.notes?.length) { if (on('showNotes') && item.notes?.length) {
const notes = item.notes.filter(notesWanted); const notes = item.notes.filter(notesWanted);
if (notes.length) { if (notes.length) {
const notesEl = document.createElement('div'); const notesEl = document.createElement('div');
@@ -309,23 +368,25 @@ export default function create({ root, moduleApi }) {
} }
row.appendChild(main); row.appendChild(main);
const lenCol = document.createElement('div'); const wantRuntime = on('showItemRuntime') && state?.live?.following;
lenCol.className = 'pp-col-len'; if (on('showLength') || wantRuntime) {
if (item.itemType !== 'header') { const lenCol = document.createElement('div');
if (p.showLength) { lenCol.className = 'pp-col-len';
const len = document.createElement('div'); if (!isHeader) {
len.className = 'pp-len'; if (on('showLength')) {
len.textContent = item.length ? fmtDur(item.length) : ''; const len = document.createElement('div');
lenCol.appendChild(len); len.className = 'pp-len';
} len.textContent = item.length ? fmtDur(item.length) : '';
if (p.showRuntime && state?.live?.following) { lenCol.appendChild(len);
const el = document.createElement('div'); }
el.className = 'pp-elapsed'; if (wantRuntime) {
lenCol.appendChild(el); const el = document.createElement('div');
el.className = 'pp-elapsed';
lenCol.appendChild(el);
}
} }
row.appendChild(lenCol);
} }
lenCol.hidden = !p.showLength && !(p.showRuntime && state?.live?.following);
row.appendChild(lenCol);
return row; return row;
} }
@@ -359,15 +420,18 @@ export default function create({ root, moduleApi }) {
return; return;
} }
// Rebuild rows only when the plan content or the display prefs changed; // Rebuild rows only when the plan content or the display settings
// live highlighting and timers are applied in place. // changed; live highlighting and timers are applied in place.
const p = prefs(); const key = JSON.stringify([state.plan.id, state.items, cfg(), state.live?.following]);
const key = JSON.stringify([state.plan.id, state.items, p, state.live?.following]);
if (key !== lastListKey) { if (key !== lastListKey) {
lastListKey = key; lastListKey = key;
listEl.innerHTML = ''; listEl.innerHTML = '';
const notesWanted = noteFilter(); const notesWanted = noteFilter();
for (const item of visibleItems()) listEl.appendChild(buildRow(item, p, notesWanted)); let number = 0;
for (const item of visibleItems()) {
if (item.itemType !== 'header') number += 1;
listEl.appendChild(buildRow(item, number, notesWanted));
}
lastCurrentId = null; // force the highlight + scroll pass lastCurrentId = null; // force the highlight + scroll pass
} }
renderLiveMarks(); renderLiveMarks();
@@ -376,14 +440,11 @@ export default function create({ root, moduleApi }) {
/** Current / done classes and the elapsed timers, without rebuilding rows. */ /** Current / done classes and the elapsed timers, without rebuilding rows. */
function renderLiveMarks() { function renderLiveMarks() {
const live = state?.live || {}; const live = state?.live || {};
const p = prefs();
const now = serverNow(); const now = serverNow();
const currentId = live.following ? live.currentItemId || '' : ''; const currentId = live.following ? live.currentItemId || '' : '';
const history = live.history || {}; const history = live.history || {};
const byId = new Map(state.items.map((it) => [it.id, it])); 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. // Everything before the current item counts as done, matched or not.
let seenCurrent = !currentId;
const doneIds = new Set(); const doneIds = new Set();
if (currentId) { if (currentId) {
for (const it of state.items) { for (const it of state.items) {
@@ -391,13 +452,12 @@ export default function create({ root, moduleApi }) {
if (it.itemType !== 'header') doneIds.add(it.id); if (it.itemType !== 'header') doneIds.add(it.id);
} }
} }
for (const row of rows) { const showNow = on('showNowIndicator');
for (const row of listEl.querySelectorAll('.pp-row')) {
const id = row.dataset.id; const id = row.dataset.id;
const item = byId.get(id); const item = byId.get(id);
const isCurrent = id === currentId; row.classList.toggle('is-current', showNow && id === currentId);
row.classList.toggle('is-current', isCurrent); row.classList.toggle('is-done', on('dimCompleted') && doneIds.has(id));
row.classList.toggle('is-done', p.dimCompleted && doneIds.has(id));
if (isCurrent) seenCurrent = true;
const elapsedEl = row.querySelector('.pp-elapsed'); const elapsedEl = row.querySelector('.pp-elapsed');
if (!elapsedEl || !item) continue; if (!elapsedEl || !item) continue;
const h = history[id]; const h = history[id];
@@ -415,10 +475,9 @@ export default function create({ root, moduleApi }) {
} }
if (!h.endedAt) elapsedEl.classList.add('is-running'); if (!h.endedAt) elapsedEl.classList.add('is-running');
} }
void seenCurrent;
if (currentId !== lastCurrentId) { if (currentId !== lastCurrentId) {
lastCurrentId = currentId; lastCurrentId = currentId;
if (currentId && p.autoScroll && Date.now() - userScrolledAt > 8000) { if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`); const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
row?.scrollIntoView({ block: 'center', behavior: 'smooth' }); row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
} }
@@ -429,7 +488,7 @@ export default function create({ root, moduleApi }) {
function renderFoot() { function renderFoot() {
const live = state?.live; const live = state?.live;
const show = prefs().showLiveFooter && live?.following && state?.plan; const show = on('showLiveFooter') && live?.following && state?.plan;
footEl.hidden = !show; footEl.hidden = !show;
footEl.className = 'pp-foot'; footEl.className = 'pp-foot';
if (!show) return; if (!show) return;
@@ -466,6 +525,9 @@ export default function create({ root, moduleApi }) {
function render() { function render() {
wrap.classList.toggle('is-offline', feedOffline); wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
const keyColor = String(cfg().keyColor || '').trim();
wrap.style.setProperty('--pp-key', /^[#a-zA-Z0-9(),.%\s-]+$/.test(keyColor) ? keyColor : '#4ea1ff');
renderStatus(); renderStatus();
if (!state) { if (!state) {
headEl.hidden = true; headEl.hidden = true;
@@ -479,7 +541,7 @@ export default function create({ root, moduleApi }) {
} }
function applyPrefs() { function applyPrefs() {
const size = Math.min(40, Math.max(9, Number(prefs().textSize) || 14)); const size = Math.min(40, Math.max(9, Number(tilePrefs().textSize) || 14));
wrap.style.setProperty('--pp-size', `${size}px`); wrap.style.setProperty('--pp-size', `${size}px`);
lastListKey = ''; lastListKey = '';
render(); render();
@@ -488,7 +550,7 @@ export default function create({ root, moduleApi }) {
function tick() { function tick() {
if (!state?.plan) return; if (!state?.plan) return;
renderClock(); renderClock();
if (state.live?.following && prefs().showRuntime) renderLiveMarks(); if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
} }
/* ── live feed ──────────────────────────────────────────────────── */ /* ── live feed ──────────────────────────────────────────────────── */
@@ -550,7 +612,8 @@ export default function create({ root, moduleApi }) {
root.innerHTML = ''; root.innerHTML = '';
}, },
onConfigChange() { onConfigChange() {
// The server re-inits and its stream re-sends the state; nothing to cache here. // Display settings changed in Admin: re-render from the new
// moduleApi.config. The server re-inits too and re-sends its state.
lastListKey = ''; lastListKey = '';
render(); render();
}, },
+89 -34
View File
@@ -1,38 +1,58 @@
{ {
"id": "pco-plan", "id": "pco-plan",
"name": "PCO Plan", "name": "PCO Plan",
"version": "1.0.0", "version": "1.1.0",
"description": "Order of service from Planning Center Services, following ProPresenter live. Pick the service type at /modules/pco-plan/setup.html", "description": "Order of service from Planning Center Services, following ProPresenter live",
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 3, "h": 3 }, "minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 5, "h": 6 }, "defaultSize": { "w": 5, "h": 6 },
"configGroups": {
"Planning Center credentials": {
"collapsed": "whenSet",
"help": "Create a Personal Access Token at api.planningcenteronline.com (Application ID + Secret), or paste an OAuth 2.0 access token instead. Saved secrets never reach browsers."
},
"Show / hide": {
"collapsed": true,
"columns": 3,
"help": "Untick anything the tiles should not show. Applies to every PCO Plan tile."
}
},
"configSchema": { "configSchema": {
"appId": { "appId": {
"type": "string", "type": "string",
"label": "Planning Center Application ID (Personal Access Token)", "label": "Application ID",
"default": "" "default": "",
"group": "Planning Center credentials"
}, },
"secret": { "secret": {
"type": "password", "type": "password",
"label": "Planning Center Secret (Personal Access Token)", "label": "Secret",
"default": "" "default": "",
"group": "Planning Center credentials"
}, },
"accessToken": { "accessToken": {
"type": "password", "type": "password",
"label": "OAuth 2.0 access token (alternative to App ID + Secret)", "label": "OAuth 2.0 access token (instead of Application ID + Secret)",
"default": "" "default": "",
"group": "Planning Center credentials"
}, },
"serviceTypeId": { "serviceTypeId": {
"type": "string", "type": "select",
"label": "Service type ID — browse folders and pick one at /modules/pco-plan/setup.html", "label": "Service type",
"default": "" "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": { "planId": {
"type": "string", "type": "select",
"label": "Pin a plan ID (blank = today's plan, else the next upcoming)", "label": "Plan",
"default": "" "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": { "pollSeconds": {
"type": "number", "type": "number",
@@ -40,28 +60,63 @@
"default": 30 "default": 30
}, },
"followProPresenter": { "followProPresenter": {
"type": "boolean", "type": "switch",
"label": "Follow the ProPresenter module's live item to highlight the current plan item", "label": "Follow the ProPresenter module's live item to highlight the current plan item and time it",
"default": true "default": true
} },
"noteCategories": {
"type": "string",
"label": "Item note categories to show (comma-separated, blank = all)",
"default": ""
},
"headerTemplate": {
"type": "string",
"label": "Top bar text",
"default": "{series} • {part} | {serviceType} • {date}",
"group": "Top bar",
"help": "Placeholders: {series} series name · {part} plan title / part number · {serviceType} · {date} · {shortDate} · {time} service start time · {length} planned length · {items} item count. Use • between items (an item whose placeholders are empty or hidden is dropped) and | to start a second line."
},
"tintByType": {
"type": "boolean",
"label": "Background color per item type (song / media / other)",
"default": false,
"group": "Top bar"
},
"keyColor": {
"type": "string",
"label": "Song key color",
"default": "#4ea1ff",
"group": "Top bar",
"help": "Any CSS color, e.g. #4ea1ff or dodgerblue."
},
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
"showServiceType": { "type": "boolean", "label": "Service type", "default": true, "group": "Show / hide" },
"showDate": { "type": "boolean", "label": "Plan date", "default": true, "group": "Show / hide" },
"showServiceTime": { "type": "boolean", "label": "Service time", "default": true, "group": "Show / hide" },
"showTotalLength": { "type": "boolean", "label": "Planned total length", "default": true, "group": "Show / hide" },
"showRunningClock": { "type": "boolean", "label": "Running clock (ahead / behind)", "default": true, "group": "Show / hide" },
"showCountdown": { "type": "boolean", "label": "Countdown to service start", "default": true, "group": "Show / hide" },
"showHeaders": { "type": "boolean", "label": "Section headers", "default": true, "group": "Show / hide" },
"showItemNumbers": { "type": "boolean", "label": "Item numbers", "default": false, "group": "Show / hide" },
"showStartTimes": { "type": "boolean", "label": "Scheduled start times", "default": true, "group": "Show / hide" },
"showLength": { "type": "boolean", "label": "Item length", "default": true, "group": "Show / hide" },
"showItemType": { "type": "boolean", "label": "Item type badge", "default": true, "group": "Show / hide" },
"showKey": { "type": "boolean", "label": "Song key", "default": true, "group": "Show / hide" },
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
"showNotes": { "type": "boolean", "label": "Item notes", "default": true, "group": "Show / hide" },
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
"showItemRuntime": { "type": "boolean", "label": "Item runtime (elapsed vs planned)", "default": true, "group": "Show / hide" },
"dimCompleted": { "type": "boolean", "label": "Dim completed items", "default": true, "group": "Show / hide" },
"showLiveFooter": { "type": "boolean", "label": "ProPresenter match line", "default": true, "group": "Show / hide" }
}, },
"instanceSchema": { "instanceSchema": {
"showPlanHeader": { "type": "boolean", "label": "Show plan title, date and service time", "default": true }, "textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
"showStartTimes": { "type": "boolean", "label": "Show scheduled start time of each item", "default": true }, "autoScroll": { "type": "boolean", "label": "Keep the current item in view", "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 }
} }
} }
+76 -46
View File
@@ -10,8 +10,9 @@
* GET /state → { state, now } (instant paint for new tiles) * GET /state → { state, now } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated) * GET /stream → SSE, `state` events (change-detected, heartbeated)
* POST /refresh → re-read the plan now * POST /refresh → re-read the plan now
* GET /browse → folders + service types, for the setup page * GET /service-types → { options } for the admin "Service type" select,
* GET /preview?serviceTypeId=… → which plan the module would pick * 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): * Planning Center API v2 (https://api.planningcenteronline.com/services/v2):
* Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0 * Auth: Personal Access Token (HTTP Basic app_id:secret) or an OAuth 2.0
@@ -392,20 +393,24 @@ module.exports = {
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`; return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`;
} }
/** Today's plan, else the next upcoming one, else the most recent past one. */ /** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
async function choosePlanId(stId) { async function listPlans(stId, { future = 5, past = 3 } = {}) {
const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`; const base = `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`;
const [future, past] = await Promise.all([ const [fut, pst] = await Promise.all([
pco.get(base, { filter: 'future', order: 'sort_date', per_page: 5 }), pco.get(base, { filter: 'future', order: 'sort_date', per_page: future }),
pco.get(base, { filter: 'past', order: '-sort_date', per_page: 3 }), pco.get(base, { filter: 'past', order: '-sort_date', per_page: past }),
]); ]);
const byId = new Map(); const byId = new Map();
for (const rec of [...(future?.data || []), ...(past?.data || [])]) { for (const rec of [...(fut?.data || []), ...(pst?.data || [])]) {
const sortDate = Date.parse(rec?.attributes?.sort_date || ''); const sortDate = Date.parse(rec?.attributes?.sort_date || '');
if (!rec?.id || !Number.isFinite(sortDate)) continue; if (!rec?.id || !Number.isFinite(sortDate)) continue;
byId.set(String(rec.id), { rec, sortDate }); byId.set(String(rec.id), { rec, sortDate });
} }
const plans = Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate); return Array.from(byId.values()).sort((x, y) => x.sortDate - y.sortDate);
}
/** Today's plan, else the next upcoming one, else the most recent past one. */
function pickPlan(plans) {
if (!plans.length) return null; if (!plans.length) return null;
const now = Date.now(); const now = Date.now();
const today = localYmd(new Date(now)); const today = localYmd(new Date(now));
@@ -416,6 +421,10 @@ module.exports = {
return plans[plans.length - 1].rec; return plans[plans.length - 1].rec;
} }
async function choosePlanId(stId) {
return pickPlan(await listPlans(stId));
}
async function readPlanTimes(planId) { async function readPlanTimes(planId) {
const body = await pco.getAll(`${planPath(planId)}/plan_times`); const body = await pco.getAll(`${planPath(planId)}/plan_times`);
return (body.data || []) return (body.data || [])
@@ -694,7 +703,7 @@ module.exports = {
state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin'; state.lastError = 'No Planning Center credentials — add an Application ID + Secret (or OAuth token) in /admin';
log(state.lastError); log(state.lastError);
} else if (!serviceTypeId) { } else if (!serviceTypeId) {
state.lastError = 'No service type selected — open /modules/pco-plan/setup.html'; state.lastError = 'No service type selected — choose one under PCO Plan in /admin';
log(state.lastError); log(state.lastError);
} else { } else {
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`); log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
@@ -725,7 +734,8 @@ module.exports = {
clearTimeout(pollTimer); clearTimeout(pollTimer);
return poll(); return poll();
}, },
choosePlanId, listPlans,
pickPlan,
}; };
return { return {
@@ -743,7 +753,7 @@ module.exports = {
}, },
health() { health() {
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' }; if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — open /modules/pco-plan/setup.html' }; if (!serviceTypeId) return { status: 'error', message: 'No service type selected — pick one in the Service type list below' };
if (!state.reachable) { if (!state.reachable) {
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' }; return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
} }
@@ -793,13 +803,15 @@ module.exports = {
); );
}, },
/** Folders + service types as flat lists; the setup page builds the tree. */ /**
'GET /browse': async (req, res) => { * Options for the admin page's "Service type" select: every service
* type, grouped by its Planning Center folder path — so the folders
* are browsable as <optgroup>s right in the admin form.
*/
'GET /service-types': async (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' }); if (!current) return json(res, 503, { error: 'Module not running.' });
const { pco } = current; const { pco } = current;
if (!pco.configured) { if (!pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
return json(res, 400, { error: 'No Planning Center credentials — add them in /admin first.', unconfigured: true });
}
try { try {
const [folderBody, stBody] = await Promise.all([ const [folderBody, stBody] = await Promise.all([
pco.getAll(`${SERVICES}/folders`), pco.getAll(`${SERVICES}/folders`),
@@ -838,44 +850,62 @@ module.exports = {
folders.set(id, { id, name: `Folder ${id}`, parentId: '' }); folders.set(id, { id, name: `Folder ${id}`, parentId: '' });
} }
} }
const state = current.getState(); // "Campuses › North Campus" — the folder path from the root down.
json(res, 200, { const pathOf = (parentId) => {
folders: Array.from(folders.values()), const names = [];
serviceTypes, let id = parentId;
selected: state.serviceType.id, const seen = new Set();
selectedName: state.serviceType.name, while (id && folders.has(id) && !seen.has(id)) {
apiBase: API_BASE, seen.add(id);
method: pco.method, 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) { } catch (err) {
log(`browse failed: ${err?.message || err}`); log(`service type list failed: ${err?.message || err}`);
json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) }); json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) });
} }
}, },
/** Which plan would be followed for a service type — shown before saving. */ /**
'GET /preview': async (req, res) => { * Options for the admin page's "Plan" select, for the service type the
* admin currently has picked (?serviceTypeId=…): the upcoming and recent
* plans, with the one Automatic would choose marked.
*/
'GET /plans': async (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' }); if (!current) return json(res, 503, { error: 'Module not running.' });
const { pco } = current;
const params = new URLSearchParams(req.search || ''); const params = new URLSearchParams(req.search || '');
const stId = String(params.get('serviceTypeId') || '').trim(); const stId = String(params.get('serviceTypeId') || '').trim();
if (!stId) return json(res, 400, { error: 'serviceTypeId is required.' }); const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
if (!pco.configured || !stId) return json(res, 200, { options: [auto] });
try { try {
const rec = await current.choosePlanId(stId); const plans = await current.listPlans(stId, { future: 8, past: 6 });
if (!rec) return json(res, 200, { plan: null }); const picked = current.pickPlan(plans);
const a = rec.attributes || {}; const now = Date.now();
json(res, 200, { const options = [auto];
plan: { // Upcoming first (soonest at the top), then the recent ones.
id: String(rec.id), const ordered = [
title: String(a.title || ''), ...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
seriesTitle: String(a.series_title || ''), ...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
dates: String(a.dates || ''), ];
itemsCount: Number(a.items_count) || 0, for (const { rec, sortDate } of ordered) {
totalLength: Number(a.total_length) || 0, const a = rec.attributes || {};
sortDate: a.sort_date || '', 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) { } catch (err) {
json(res, err?.status === 401 ? 401 : 502, { error: err?.message || String(err) }); json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) });
} }
}, },
}; };
+70 -26
View File
@@ -1,8 +1,10 @@
/* PCO Plan module — scoped to its tiles, shell variables only. /* PCO Plan module — scoped to its tiles, shell variables only (plus the
Everything scales from --pp-size (set from the tile's text-size setting). */ 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 { [data-module="pco-plan"] .pp-wrap {
--pp-size: 14px; --pp-size: 14px;
--pp-key: #4ea1ff;
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
@@ -11,7 +13,7 @@
color: var(--text); color: var(--text);
} }
/* ── plan header ── */ /* ── top bar ── */
[data-module="pco-plan"] .pp-head { [data-module="pco-plan"] .pp-head {
flex: none; flex: none;
@@ -28,8 +30,9 @@
[data-module="pco-plan"] .pp-plan { flex: 1; min-width: 0; } [data-module="pco-plan"] .pp-plan { flex: 1; min-width: 0; }
[data-module="pco-plan"] .pp-plan-title { [data-module="pco-plan"] .pp-plan-title {
font-size: 1.05em; font-size: 1.1em;
font-weight: 700; font-weight: 700;
line-height: 1.25;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -37,12 +40,13 @@
[data-module="pco-plan"] .pp-plan-sub { [data-module="pco-plan"] .pp-plan-sub {
margin-top: 0.15em; margin-top: 0.15em;
font-size: 0.8em; font-size: 0.82em;
color: var(--muted); color: var(--muted);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
[data-module="pco-plan"] .pp-plan-sub[hidden] { display: none; }
[data-module="pco-plan"] .pp-clock { [data-module="pco-plan"] .pp-clock {
flex: none; flex: none;
@@ -91,22 +95,36 @@
[data-module="pco-plan"] .pp-row + .pp-row { margin-top: 0.15em; } [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:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
/* Optional background tint per item type (admin "Background color per item type"). */
[data-module="pco-plan"] .tint-types .pp-row.is-song { background: color-mix(in srgb, var(--accent) 9%, transparent); }
[data-module="pco-plan"] .tint-types .pp-row.is-media { background: color-mix(in srgb, var(--warn) 10%, transparent); }
[data-module="pco-plan"] .tint-types .pp-row.is-item { background: color-mix(in srgb, var(--text) 5%, transparent); }
[data-module="pco-plan"] .tint-types .pp-row.is-song:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
[data-module="pco-plan"] .tint-types .pp-row.is-media:hover { background: color-mix(in srgb, var(--warn) 15%, transparent); }
[data-module="pco-plan"] .tint-types .pp-row.is-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
/* Section headers: a full-width band so a new part of the service is
unmistakable at a glance — accent bar, tinted ground, spaced capitals. */
[data-module="pco-plan"] .pp-row.is-header { [data-module="pco-plan"] .pp-row.is-header {
margin-top: 0.6em; margin-top: 0.9em;
padding-top: 0.5em; padding: 0.5em 0.7em 0.45em;
padding-bottom: 0.15em; align-items: center;
border-top: 1px solid var(--border); border-left: 3px solid var(--accent);
border-radius: 0; border-radius: 0.4em;
background: none; background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 22%, transparent), color-mix(in srgb, var(--accent) 5%, transparent) 70%, transparent);
} }
[data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0; border-top: none; } [data-module="pco-plan"] .pp-row.is-header:first-child { margin-top: 0.15em; }
[data-module="pco-plan"] .pp-row.is-header:hover { background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 26%, transparent), color-mix(in srgb, var(--accent) 7%, transparent) 70%, transparent); }
[data-module="pco-plan"] .pp-row.is-header .pp-title { [data-module="pco-plan"] .pp-row.is-header .pp-title {
font-size: 0.78em; font-size: 0.85em;
font-weight: 800; font-weight: 800;
letter-spacing: 0.12em; letter-spacing: 0.16em;
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--text);
} }
[data-module="pco-plan"] .pp-row.is-header .pp-col-time,
[data-module="pco-plan"] .pp-row.is-header .pp-col-num,
[data-module="pco-plan"] .pp-row.is-header .pp-col-len { visibility: hidden; }
[data-module="pco-plan"] .pp-row.is-pre .pp-title, [data-module="pco-plan"] .pp-row.is-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-post .pp-title { color: color-mix(in srgb, var(--text) 75%, var(--muted)); }
@@ -116,10 +134,21 @@
[data-module="pco-plan"] .pp-row.is-current { [data-module="pco-plan"] .pp-row.is-current {
border-left-color: var(--accent); border-left-color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, transparent); background: color-mix(in srgb, var(--accent) 16%, transparent);
opacity: 1; 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 { [data-module="pco-plan"] .pp-col-time {
flex: none; flex: none;
width: 4.6em; width: 4.6em;
@@ -130,14 +159,13 @@
text-align: right; text-align: right;
white-space: nowrap; 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-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-col-main { flex: 1; min-width: 0; }
[data-module="pco-plan"] .pp-title-line { [data-module="pco-plan"] .pp-title-line {
display: flex; display: flex;
align-items: baseline; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.2em 0.55em; gap: 0.2em 0.55em;
min-width: 0; min-width: 0;
@@ -149,6 +177,21 @@
overflow-wrap: anywhere; 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 { [data-module="pco-plan"] .pp-now-tag {
display: none; display: none;
padding: 0.05em 0.45em; padding: 0.05em 0.45em;
@@ -159,12 +202,10 @@
font-weight: 800; font-weight: 800;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; 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-row.is-current .pp-now-tag { display: inline-block; }
[data-module="pco-plan"] .pp-badge, [data-module="pco-plan"] .pp-badge,
[data-module="pco-plan"] .pp-key,
[data-module="pco-plan"] .pp-arr { [data-module="pco-plan"] .pp-arr {
font-size: 0.7em; font-size: 0.7em;
font-weight: 700; font-weight: 700;
@@ -180,13 +221,17 @@
} }
[data-module="pco-plan"] .pp-badge.is-song { color: color-mix(in srgb, var(--accent) 80%, var(--text)); border-color: color-mix(in srgb, var(--accent) 40%, transparent); } [data-module="pco-plan"] .pp-badge.is-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-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-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 { [data-module="pco-plan"] .pp-desc {
margin-top: 0.15em; margin-top: 0.15em;
font-size: 0.86em; font-size: 0.86em;
@@ -230,7 +275,6 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
} }
[data-module="pco-plan"] .pp-col-len[hidden] { display: none; }
[data-module="pco-plan"] .pp-len { [data-module="pco-plan"] .pp-len {
font-size: 0.85em; font-size: 0.85em;
color: var(--muted); color: var(--muted);
+3 -3
View File
@@ -84,9 +84,9 @@ function plansFor(stId) {
], ],
}); });
return [ return [
mk(`${stId}-P1`, firstServiceStart - 7 * DAY, 'The Table', 'Living Hope'), mk(`${stId}-P1`, firstServiceStart - 7 * DAY, 'Part 1', 'Living Hope'),
mk(`${stId}-P2`, firstServiceStart, 'Living Hope Wk 2', 'Living Hope'), mk(`${stId}-P2`, firstServiceStart, 'Part 2', 'Living Hope'),
mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Living Hope Wk 3', 'Living Hope'), mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Part 3', 'Living Hope'),
]; ];
} }