PCO Plan 1.9.0: a Song Key tile, and AbleSet-shaped section chips

**Song Key**, a third entry in the + picker: the key of the item
ProPresenter has live, as large as the tile allows. It is set as a note —
B♭, F♯m rather than Bb and F#m — with the song's name and the next keyed
song under it. All three are tile settings: the colour, the name and the
next key. There is no new connection; it reads the same streamed state the
Plan tile does.

The accidental and the quality sit on the same line as the letter. Raising
the accidental read as two stacked characters at tile sizes, which is worse
than plain at a glance from the back of a room.

When the ProPresenter feed that picks the item goes away the key stays up —
the band still needs it — but faded, and the tile's dot says it is the last
item rather than the current one.

**The section chips** on the Plan tile's current item are rounded squares
now, with the tinted ground and transparent border the AbleSet module gives
its own section pills, so the two read as one family. The chip playing keeps
its own ground so its progress fills against the chip rather than against
the row behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-27 11:18:25 -04:00
co-authored by Claude Opus 5
parent aec9c47ed4
commit c9d7c02bd6
5 changed files with 319 additions and 14 deletions
+9 -2
View File
@@ -323,8 +323,9 @@ below. Nothing is installed without someone clicking.
session (Start ProdDash.command) and approve the one-time prompt; a server
started over SSH or by bare launchd silently captures zeros. (Engine
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
- **PCO Plan** — Planning Center Services, as two tiles in the + picker:
**Plan** (the order of service) and **Teams** (who is scheduled).
- **PCO Plan** — Planning Center Services, as three tiles in the + picker:
**Plan** (the order of service), **Teams** (who is scheduled) and **Song
Key** (the key of the item on screen).
**Plan** shows every
plan item with its scheduled start time, length, type, song key,
@@ -355,6 +356,12 @@ below. Nothing is installed without someone clicking.
is switched off, or it has no host — nothing to turn off and nothing to
explain.
**Song Key** is one value, as large as the tile allows: the key of the item
ProPresenter has live, set as a note (B♭, F♯m), with the song's name and the
next key under it — both switches, as is the colour. When the feed that
picks the item is gone the key stays up, faded, and the tile's dot says so
rather than pretending to be current.
**Teams** shows the same plan's team and position assignments — each team
as a band of its people, with their photo (fetched through ProdDash, never
by the browser; initials when Planning Center has none) and a dot for
+6 -4
View File
@@ -1,8 +1,8 @@
/* PCO Plan — client part.
The module presents two tiles and this is the entry for both: the Teams
tile (picker entry "teams") is built by teams-client.js, everything below
draws the Plan tile.
The module presents three tiles and this is the entry for all of them: the
Teams tile (picker entry "teams") is built by teams-client.js and the Song
Key tile ("key") by key-client.js; everything below draws the Plan tile.
The server part reads Planning Center and (optionally) follows the
ProPresenter module's live item; this tile only renders the plan it is
@@ -13,6 +13,7 @@
the connection settings. */
import createTeams from "./teams-client.js";
import createKey from "./key-client.js";
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>';
@@ -21,8 +22,9 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
export default function create({ root, moduleApi }) {
// Tiles added as "Teams" carry that variant and have their own settings.
// Each picker entry carries its variant and its own settings.
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
if (moduleApi.variant === "key") return createKey({ root, moduleApi });
return createPlan({ root, moduleApi });
}
+186
View File
@@ -0,0 +1,186 @@
/* PCO Plan — the Song Key tile.
One thing, big: the key of the plan item on screen. It reads the same
streamed state the Plan tile does, so it needs no connection of its own;
which item is current comes from the ProPresenter module, as ever. */
export default function createKey({ root, moduleApi }) {
let state = null;
let stream = null;
let feedOffline = false;
root.innerHTML = `
<div class="pk-wrap">
<div class="pk-key"></div>
<div class="pk-name"></div>
<div class="pk-next"></div>
</div>`;
const wrap = root.querySelector('.pk-wrap');
const keyEl = root.querySelector('.pk-key');
const nameEl = root.querySelector('.pk-name');
const nextEl = root.querySelector('.pk-next');
const cfg = () => moduleApi.instanceSettings || {};
const on = (key) => cfg()[key] !== false; // unset = shown
/* ── the item on screen, and the one after it ───────────────────── */
function items() {
return Array.isArray(state?.items) ? state.items : [];
}
function currentItem() {
const id = state?.live?.following ? state.live.currentItemId : '';
return id ? items().find((it) => it.id === id) || null : null;
}
/** The next item that actually has a key — a talk between two songs is skipped. */
function nextKeyed(after) {
const list = items();
const from = after ? list.findIndex((it) => it.id === after.id) + 1 : 0;
if (from <= 0) return null;
for (let i = from; i < list.length; i += 1) {
if (list[i].keyName) return list[i];
}
return null;
}
function titleOf(item) {
return item ? (item.song?.title || item.title || '') : '';
}
/* ── render ─────────────────────────────────────────────────────── */
function renderStatus() {
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
const item = currentItem();
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
// The key on screen stays — the band still needs it — but a tile must
// never look confidently live while the feed that chose it is gone.
if (!state.live.available) {
return moduleApi.setStatus('error', `${state.live.reason || 'ProPresenter feed unavailable'} — showing the last item`);
}
if (!state.live.ppReachable) return moduleApi.setStatus('error', 'ProPresenter unreachable — showing the last item');
if (!item) return moduleApi.setStatus('ok', 'Waiting for an item to go live');
moduleApi.setStatus('ok', `${titleOf(item)}${item.keyName ? ` — ${item.keyName}` : ' — no key in the plan'}`);
}
/** "Bb" → "B♭", "F#m" → "F♯m" — for the plain-text line under the key. */
function prettyKey(text) {
const match = /^([A-G])([#b])(.*)$/.exec(String(text || '').trim());
return match ? `${match[1]}${match[2] === '#' ? '♯' : '♭'}${match[3]}` : String(text || '');
}
/**
* A key is a note, so it is worth setting properly: "Bb" and "F#m" read
* better with the accidental raised and the minor kept small.
*/
function drawKey(text) {
keyEl.innerHTML = '';
const match = /^([A-G])([#b♯♭]?)(.*)$/.exec(String(text).trim());
if (!match) {
keyEl.textContent = text;
return;
}
const [, note, accidental, rest] = match;
keyEl.appendChild(document.createTextNode(note));
if (accidental) {
const sup = document.createElement('span');
sup.className = 'pk-accidental';
sup.textContent = accidental === '#' ? '♯' : accidental === 'b' ? '♭' : accidental;
keyEl.appendChild(sup);
}
if (rest) {
const tail = document.createElement('span');
tail.className = 'pk-quality';
tail.textContent = rest;
keyEl.appendChild(tail);
}
}
function render() {
wrap.classList.toggle('is-offline', feedOffline);
const live = state?.live;
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
const color = String(cfg().keyColor || '').trim();
wrap.style.setProperty('--pk-color', /^[#a-zA-Z0-9(),.%\s-]+$/.test(color) && color ? color : '#4ea1ff');
renderStatus();
const item = currentItem();
const key = item?.keyName || '';
wrap.classList.toggle('has-key', Boolean(key));
if (!state || !state.configured || !state.serviceTypeSet) {
drawKey('—');
nameEl.textContent = state ? 'Set up PCO Plan in Admin' : 'Connecting…';
nextEl.textContent = '';
return;
}
if (!item) {
drawKey('—');
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter';
nextEl.textContent = '';
return;
}
drawKey(key || '—');
nameEl.textContent = on('showSongName') ? titleOf(item) : '';
nameEl.hidden = !nameEl.textContent;
const next = on('showNextKey') ? nextKeyed(item) : null;
nextEl.textContent = next ? `Next: ${prettyKey(next.keyName)} · ${titleOf(next)}` : '';
nextEl.hidden = !nextEl.textContent;
}
/* ── live feed ──────────────────────────────────────────────────── */
function accept(payload) {
if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state;
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 */ }
},
},
});
}
/* ── lifecycle ──────────────────────────────────────────────────── */
return {
start() {
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();
render();
},
stop() {
stream?.close();
stream = null;
root.innerHTML = '';
},
onConfigChange() {
// Admin holds the connection only; the server re-inits and re-sends.
},
};
}
+32 -2
View File
@@ -1,9 +1,9 @@
{
"id": "pco-plan",
"name": "PCO Plan",
"version": "1.8.0",
"version": "1.9.0",
"proddash": ">=1.4.0",
"description": "Order of service and team assignments from Planning Center Services",
"description": "Order of service, team assignments and the song key from Planning Center Services",
"provides": [
"timers"
],
@@ -621,6 +621,36 @@
"columns": 2
}
}
},
{
"id": "key",
"name": "Song Key",
"description": "The key of the item on screen, big",
"defaultSize": {
"w": 2,
"h": 2
},
"minSize": {
"w": 1,
"h": 1
},
"instanceSchema": {
"keyColor": {
"type": "color",
"label": "Key",
"default": "#4ea1ff"
},
"showSongName": {
"type": "boolean",
"label": "Song name",
"default": true
},
"showNextKey": {
"type": "boolean",
"label": "Next key",
"default": true
}
}
}
]
}
+86 -6
View File
@@ -635,25 +635,105 @@
[data-module="pco-plan"] .pp-abs-sec {
flex: none;
padding: 0.1em 0.5em;
border-radius: 999px;
border: 1px solid var(--border);
padding: 0.14em 0.55em;
/* The same rounded square, tinted ground and transparent border the AbleSet
module gives its own section pills, so the two read as one family. */
border-radius: 0.35em;
border: 1px solid transparent;
background: color-mix(in srgb, var(--text) 8%, var(--panel));
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
color: var(--muted);
transition: color 0.2s ease, border-color 0.2s ease;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
/* The section now playing fills left to right as it goes, like the row. */
/* The section playing keeps its own ground and fills across it, so the
progress reads against the chip rather than against the row behind it. */
[data-module="pco-plan"] .pp-abs-sec.is-now {
color: var(--text);
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
background:
linear-gradient(90deg,
color-mix(in srgb, var(--pp-current) 40%, transparent) var(--pp-sec-fill, 0%),
transparent var(--pp-sec-fill, 0%));
color-mix(in srgb, var(--pp-current) 45%, var(--panel)) var(--pp-sec-fill, 0%),
color-mix(in srgb, var(--text) 8%, var(--panel)) var(--pp-sec-fill, 0%));
}
/* ── the Song Key tile ────────────────────────────────────────────────
One value, as large as the tile allows. Sizes come from the tile itself
(container units), like the AbleSet module's single readouts, so a small
tile and a wall-sized one both read properly. */
[data-module="pco-plan"] .pk-wrap {
--pk-color: #4ea1ff;
position: absolute;
inset: 0;
container-type: size;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2em;
padding: 4%;
text-align: center;
user-select: none;
}
[data-module="pco-plan"] .pk-key {
font-size: clamp(18px, 46cqmin, 260px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.01em;
color: var(--muted);
white-space: nowrap;
}
[data-module="pco-plan"] .pk-wrap.has-key .pk-key { color: var(--pk-color); }
/* A key is a note, but it has to read as one word at a glance from the back
of a room: the accidental and the quality sit on the same line as the
letter, only smaller. Raising the accidental looked like two stacked
characters at tile sizes. */
[data-module="pco-plan"] .pk-accidental {
font-size: 0.68em;
margin-left: 0.01em;
}
[data-module="pco-plan"] .pk-quality {
font-size: 0.5em;
font-weight: 700;
margin-left: 0.05em;
opacity: 0.85;
}
[data-module="pco-plan"] .pk-name {
max-width: 100%;
font-size: clamp(9px, 9cqmin, 30px);
font-weight: 600;
line-height: 1.2;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .pk-name[hidden] { display: none; }
[data-module="pco-plan"] .pk-next {
max-width: 100%;
font-size: clamp(8px, 6.5cqmin, 20px);
font-weight: 600;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .pk-next[hidden] { display: none; }
[data-module="pco-plan"] .pk-wrap.is-offline { opacity: 0.6; }
/* The feed that picks the item is gone: the key stays readable, but faded,
so nobody trusts it as current without looking at the status dot. */
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }