diff --git a/CLAUDE.md b/CLAUDE.md
index ebd9d3a..6d45a83 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -44,6 +44,7 @@ title-bar controls). The rules that matter most:
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
+ `node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
## Reference code (read-only forks — don't develop these here)
diff --git a/README.md b/README.md
index c10c64e..0c23e5a 100644
--- a/README.md
+++ b/README.md
@@ -351,6 +351,15 @@ below. Nothing is installed without someone clicking.
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices.
+- **Band Lineup** — the backstage stage board: everyone scheduled on the
+ band, vocal and production teams for the upcoming Planning Center plan,
+ placed into their mic slot with photo, name and mic/pack label; birthdays
+ in the service week get confetti. Guests and MCs not on the plan are pinned
+ per slot from the title bar's Edit button. Admin config: Planning Center
+ credentials, service type and plan, the teams to include, the stage slots
+ (one mic/pack label per line, in stage order), default columns, and a
+ Google Drive folder for manual photos. Tile settings: names, mic labels,
+ parallax, and the birthday confetti with its intensity.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
@@ -362,8 +371,11 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
`node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
-run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). See the
-testing section of the [module guide](docs/MODULE-GUIDE.md).
+run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
+`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
+board — a plan, its team members, birthdays and photos — run ProdDash with
+`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
+the [module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app
diff --git a/modules/band-lineup/README.md b/modules/band-lineup/README.md
new file mode 100644
index 0000000..eca1f6d
--- /dev/null
+++ b/modules/band-lineup/README.md
@@ -0,0 +1,52 @@
+# Band Lineup module
+
+The backstage stage board as a ProdDash tile: everyone scheduled on the band /
+vocal / production teams for the upcoming Planning Center plan, placed into their
+mic slot with photo, name and mic/pack label. People whose birthday falls in the
+current service week (Wednesday→Tuesday) get raining confetti and a gold glow.
+
+This is the display half of the standalone **Band Lineup** Electron app, ported
+to ProdDash. (The ProPresenter Now/Next half of that app is the separate
+`propresenter-now-next` module.)
+
+## How it fills the board
+
+1. **Slots** are the mic/pack labels you write in admin, one per line, in the
+ order they sit on stage — e.g. `[VOX 2] MIX 2`, `[DRUMS]`, `[MC] MC1`. A
+ blank line is an empty spot.
+2. Each refresh the server reads the upcoming plan's **team members** (any team
+ whose name matches a line in *Teams to include*), and matches each person to
+ a slot by their Planning Center **position** — the same loose matching the
+ Electron app uses (`matching.js`; `[VOX 2] MIX 2` takes whoever is on *Vox
+ 2*). A numbered role only matches the same number, and the `MIX n` part is
+ ignored, so Vox 2 and Vox 3 never swap.
+3. **Photos and birthdays** come from Planning Center (birthdays via the People
+ API; only month + day is kept). Photos are cached on disk and served by the
+ module; birthdays drive the confetti.
+
+## Manual photos (people not on the plan)
+
+Guest speakers, MCs, stage managers — anyone not scheduled in Planning Center —
+are pinned per slot from the tile's **Edit** button (title bar): set a name,
+optionally upload a photo, optionally a `MM-DD` birthday. A manual pin overrides
+the PCO match for that slot and is kept until you Clear it. Uploaded photos live
+in the ProdDash data directory, never in the repo.
+
+## Settings
+
+- **Admin** (server-wide): Planning Center credentials, service type, plan, the
+ teams to include, the stage slots, columns, refresh interval.
+- **Per tile** (gear): birthday confetti on/off + intensity, a force-birthday
+ test picker, parallax, and show/hide for names and mic labels.
+
+## Testing without Planning Center
+
+```bash
+node tools/pco-band-mock.js 24710 # mock PCO
+BAND_LINEUP_API_BASE=http://127.0.0.1:24710 node server.js # point the module at it
+node modules/band-lineup/matching.test.js # slot-matching unit tests
+```
+
+Then in `/admin` under Band Lineup enter any Client ID + Secret, pick "Sunday
+Service", and the board fills — one person (on Drums) has a birthday this week,
+so their tile rains confetti.
diff --git a/modules/band-lineup/client.js b/modules/band-lineup/client.js
new file mode 100644
index 0000000..ab86bc8
--- /dev/null
+++ b/modules/band-lineup/client.js
@@ -0,0 +1,299 @@
+// Band Lineup — client. Renders the shared stage board from /state + /stream.
+// The Edit button opens an editor: an uploaded-photo library you drag (or click)
+// into slots, plus rename / clear / upload / delete. Edits go to the server and
+// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
+// (Wed→Tue) rains confetti.
+
+const CONFETTI_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff'];
+
+export default function create({ root, moduleApi }) {
+ let stream = null;
+ let state = null;
+ let editing = false;
+ let choirBtn = null;
+ let selectedLibId = ''; // click-to-place fallback for touch
+ const canvases = new Set();
+ let raf = null;
+ // Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
+ let dragScrolling = false;
+ let dragY = 0;
+ let dragBody = null;
+ let dragRaf = null;
+ function startDragAutoScroll() {
+ if (dragRaf) return;
+ const tick = () => {
+ if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
+ const r = dragBody.getBoundingClientRect();
+ const zone = 90; // generous edge band so you don't have to be pixel-accurate
+ let dy = 0;
+ if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
+ else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
+ if (dy) dragBody.scrollTop += dy;
+ dragRaf = requestAnimationFrame(tick);
+ };
+ dragRaf = requestAnimationFrame(tick);
+ }
+ function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
+
+ /* ---------- confetti (always animates) ---------- */
+ function makeParticle(w, h, intensity, seedY) {
+ const ribbon = Math.random() < 0.55;
+ return {
+ x: Math.random() * w, y: seedY != null ? seedY : Math.random() * h,
+ w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5, h: ribbon ? 9 + Math.random() * 8 : 0, ribbon,
+ color: CONFETTI_COLORS[(Math.random() * CONFETTI_COLORS.length) | 0],
+ rot: Math.random() * Math.PI * 2, vr: -0.14 + Math.random() * 0.28,
+ sway: 0.6 + Math.random() * 1.1, phase: Math.random() * Math.PI * 2,
+ vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5),
+ };
+ }
+ function seed(c, w, h) { const base = c._intensity === 'subtle' ? 26 : 64; const n = Math.max(10, Math.round((base * (w * h)) / (300 * 500))); c._parts = Array.from({ length: n }, () => makeParticle(w, h, c._intensity)); }
+ function step(c) {
+ const w = c.clientWidth, h = c.clientHeight; if (!w || !h) return;
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ if (c._w !== w || c._h !== h) { c.width = w * dpr; c.height = h * dpr; c._ctx = c.getContext('2d'); c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); c._w = w; c._h = h; seed(c, w, h); }
+ const ctx = c._ctx; ctx.clearRect(0, 0, w, h);
+ for (const p of c._parts) {
+ p.phase += 0.03; p.y += p.vy; p.x += Math.sin(p.phase) * p.sway; p.rot += p.vr;
+ if (p.y > h + 16) Object.assign(p, makeParticle(w, h, c._intensity, -16));
+ ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.color;
+ if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }
+ ctx.restore();
+ }
+ }
+ function loop() { for (const c of [...canvases]) { if (!c.isConnected) { canvases.delete(c); continue; } step(c); } raf = canvases.size ? requestAnimationFrame(loop) : null; }
+ function addConfetti(host, intensity) { const c = document.createElement('canvas'); c.className = 'bl-confetti'; c._intensity = intensity; host.appendChild(c); canvases.add(c); if (!raf) raf = requestAnimationFrame(loop); }
+
+ /* ---------- helpers ---------- */
+ function initials(name) { const p = String(name || '').trim().split(/\s+/).filter(Boolean).slice(0, 2); return p.length ? p.map((x) => x[0]).join('').toUpperCase() : ''; }
+ // Per-tile Columns wins; 0 falls back to the admin default.
+ function resolveCols() { const per = Number(moduleApi.instanceSettings.columns) || 0; return Math.max(1, per > 0 ? per : (Number(state && state.columns) || 7)); }
+ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
+ function photoNode(person, cls) {
+ if (person && person.photo) { const img = el('img', cls || 'bl-img'); img.alt = ''; img.src = person.photo; img.loading = 'lazy'; return img; }
+ return el('div', 'bl-initials', initials(person && person.name));
+ }
+ async function api(pathStr, opts) { try { return await moduleApi.fetch(pathStr, opts); } catch { return null; } }
+
+ /* ---------- display board ---------- */
+ function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
+
+ function render() {
+ stopConfetti();
+ stopParallax();
+ root.innerHTML = '';
+ if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
+ if (editing) { renderEditor(); return; }
+
+ const board = el('div', 'bl-board');
+ if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
+
+ const s = moduleApi.instanceSettings;
+ const birthdays = s.birthdays !== false;
+ const intensity = s.birthdayIntensity === 'full' ? 'full' : 'subtle';
+ const testIndex = birthdays ? String(s.birthdayTest || '') : '';
+ const cols = resolveCols();
+ const rows = Math.max(1, Math.ceil((state.slots ? state.slots.length : 1) / cols));
+ board.style.setProperty('--bl-cols', cols);
+ board.style.setProperty('--bl-rows', rows);
+
+ for (const slot of state.slots || []) {
+ const cell = el('div', 'bl-slot');
+ const person = slot.person;
+ if (person) {
+ const tile = el('div', 'bl-tile');
+ const photo = el('div', 'bl-photo');
+ photo.appendChild(photoNode(person));
+ const celebrating = birthdays && (person.celebrating || (testIndex !== '' && testIndex === String(slot.index)));
+ if (celebrating) { tile.classList.add('bl-birthday', `bl-birthday-${intensity}`); addConfetti(photo, intensity); }
+ if (s.showNames !== false && person.name) photo.appendChild(el('div', 'bl-name', person.name));
+ tile.appendChild(photo);
+ if (s.showLabels !== false && slot.label) tile.appendChild(el('div', 'bl-label', slot.label));
+ cell.appendChild(tile);
+ } else {
+ const empty = el('div', 'bl-tile bl-empty');
+ empty.appendChild(el('div', 'bl-label bl-label-empty', slot.label || `Slot ${slot.index + 1}`));
+ cell.appendChild(empty);
+ }
+ board.appendChild(cell);
+ }
+ root.appendChild(board);
+ applyParallax(board, s.parallax !== false);
+ reportStatus();
+ }
+
+ /* ---------- parallax: each photo drifts on its own, automatically ---------- */
+ let parallaxRaf = null;
+ let parallaxImgs = [];
+ function stopParallax() { if (parallaxRaf) { cancelAnimationFrame(parallaxRaf); parallaxRaf = null; } parallaxImgs = []; }
+ function applyParallax(board, on) {
+ stopParallax();
+ const imgs = [...board.querySelectorAll('.bl-img')];
+ if (!on) { imgs.forEach((i) => { i.style.transform = ''; }); return; }
+ // Give each image its own gentle, endless drift — different amplitude, speed and phase,
+ // so the wall never looks frozen and no two photos move together. No mouse needed.
+ parallaxImgs = imgs.map((img) => ({
+ img,
+ ax: 4 + Math.random() * 5, ay: 4 + Math.random() * 5,
+ // Very slow: a full drift cycle takes roughly a minute, so the motion only
+ // registers if you actually watch for it.
+ sx: 0.03 + Math.random() * 0.05, sy: 0.03 + Math.random() * 0.05,
+ px: Math.random() * Math.PI * 2, py: Math.random() * Math.PI * 2,
+ }));
+ const tick = () => {
+ const t = performance.now() / 1000;
+ for (const p of parallaxImgs) {
+ if (!p.img.isConnected) continue;
+ p.img.style.transform = `scale(1.08) translate(${(Math.sin(t * p.sx + p.px) * p.ax).toFixed(2)}px, ${(Math.cos(t * p.sy + p.py) * p.ay).toFixed(2)}px)`;
+ }
+ parallaxRaf = requestAnimationFrame(tick);
+ };
+ parallaxRaf = requestAnimationFrame(tick);
+ }
+
+ function reportStatus() {
+ if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
+ if (state.configured && !state.reachable) return moduleApi.setStatus('error', state.lastError || 'Planning Center unreachable');
+ return moduleApi.setStatus('ok', state.plan?.dates ? `Plan: ${state.plan.dates}` : 'Ready');
+ }
+
+ /* ---------- editor ---------- */
+ function renderEditor() {
+ // No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
+ // board on top and the picture picker (library) below it.
+ const wrap = el('div', 'bl-editor bl-editor-nobar');
+ const body = el('div', 'bl-editor-body');
+ // Feed the drag auto-scroller the pointer position anywhere over the editor.
+ body.addEventListener('dragover', (e) => { dragY = e.clientY; });
+
+ // Slots (drop targets)
+ const boardEl = el('div', 'bl-edit-board');
+ boardEl.style.setProperty('--bl-cols', resolveCols());
+ for (const slot of state?.slots || []) {
+ const cell = el('div', 'bl-edit-slot');
+ cell.dataset.index = String(slot.index);
+ const photo = el('div', 'bl-edit-photo');
+ photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
+ cell.appendChild(photo);
+ // An occupied card is a drag SOURCE too — move/swap it to another slot.
+ if (slot.person) {
+ cell.draggable = true;
+ cell.addEventListener('dragstart', (e) => {
+ e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
+ dragBody = body; dragScrolling = true; startDragAutoScroll();
+ });
+ cell.addEventListener('dragend', stopDragAutoScroll);
+ }
+ const nameRow = el('div', 'bl-edit-namerow');
+ const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
+ nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
+ nameRow.appendChild(nameBtn);
+ if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
+ cell.appendChild(nameRow);
+ cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
+ // drop
+ cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
+ cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
+ cell.addEventListener('drop', (e) => {
+ e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
+ const data = e.dataTransfer.getData('text/plain') || '';
+ if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
+ else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
+ });
+ cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
+ boardEl.appendChild(cell);
+ }
+ body.appendChild(boardEl);
+
+ // Library
+ const lib = el('div', 'bl-library');
+ const list = state?.library || [];
+ if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
+ for (const person of list) {
+ const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
+ if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
+ const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
+ const nm = el('button', 'bl-lib-name', person.name || 'Name…');
+ nm.addEventListener('click', (e) => { e.stopPropagation(); renameLibrary(person.id, person.name); });
+ item.appendChild(nm);
+ const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
+ del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
+ item.appendChild(del);
+ item.addEventListener('dragstart', (e) => {
+ e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
+ dragBody = body; dragScrolling = true; startDragAutoScroll();
+ });
+ item.addEventListener('dragend', stopDragAutoScroll);
+ item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
+ lib.appendChild(item);
+ }
+ body.appendChild(lib);
+ wrap.appendChild(body);
+
+ root.innerHTML = '';
+ root.appendChild(wrap);
+ }
+
+ function assign(slotIndex, personId) {
+ selectedLibId = '';
+ api(`/assign?slot=${slotIndex}&personId=${encodeURIComponent(personId)}`, { method: 'POST' });
+ }
+ function renameSlot(slotIndex, currentName) {
+ const name = prompt('Name for this spot (leave blank to remove a name-only spot):', currentName || '');
+ if (name === null) return;
+ api(`/slot/${slotIndex}/name?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
+ }
+ function renameLibrary(id, currentName) {
+ const name = prompt('Rename this photo:', currentName || '');
+ if (name === null) return;
+ api(`/library/${id}?name=${encodeURIComponent(name.trim())}`, { method: 'POST' });
+ }
+
+ /* ---------- lifecycle ---------- */
+ return {
+ start() {
+ moduleApi.setStatus('connecting', 'Connecting…');
+ const SYNC_ICON = '';
+ const EDIT_ICON = '';
+ moduleApi.header.addButton({
+ icon: SYNC_ICON,
+ title: 'Sync',
+ onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
+ });
+ const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
+ // Columns −/+ (touch-friendly; replaces the gear number field).
+ const MINUS_ICON = '';
+ const PLUS_ICON = '';
+ const bumpCols = (d) => {
+ const max = Math.max(1, state && state.slots ? state.slots.length : 12);
+ const next = Math.max(1, Math.min(max, resolveCols() + d));
+ moduleApi.saveInstanceSettings({ columns: next });
+ render();
+ };
+ moduleApi.header.addButton({ icon: MINUS_ICON, title: 'Fewer columns', onClick() { bumpCols(-1); } });
+ moduleApi.header.addButton({ icon: PLUS_ICON, title: 'More columns', onClick() { bumpCols(1); } });
+ const CHOIR_ICON = '';
+ choirBtn = moduleApi.header.addButton({ icon: CHOIR_ICON, title: 'Choir', onClick() { api('/choir', { method: 'POST' }); } });
+ moduleApi.fetch('/state').then((r) => r.json()).then((d) => { state = d.state; render(); }).catch(() => {});
+ stream = moduleApi.sse('/stream', {
+ error() { reportStatus(); },
+ events: {
+ state(e) {
+ try { state = JSON.parse(e.data); } catch { return; }
+ // While editing, don't yank a rename prompt out from under the user:
+ // re-render the editor only (state carries board + library).
+ render();
+ },
+ },
+ });
+ },
+ onResize() {},
+ stop() {
+ if (stream) { stream.close(); stream = null; }
+ stopConfetti();
+ stopParallax();
+ stopDragAutoScroll();
+ root.innerHTML = '';
+ },
+ };
+}
diff --git a/modules/band-lineup/matching.js b/modules/band-lineup/matching.js
new file mode 100644
index 0000000..cfea022
--- /dev/null
+++ b/modules/band-lineup/matching.js
@@ -0,0 +1,198 @@
+'use strict';
+
+/**
+ * Slot ↔ Planning Center position matching for the Band Lineup module.
+ *
+ * A "slot" is a mic/pack label the operator wrote for a spot on stage, e.g.
+ * "[LEADER, VOX 1, ACOUSTIC] MIX 1". A scheduled person carries their PCO
+ * team-position name, e.g. "Vox 1" or "Acoustic / Worship Leader". We assign
+ * people to slots by loose token overlap — the same logic the backstage
+ * Electron app used, lifted here unchanged so the board matches what the booth
+ * is used to. Pure functions only: no I/O, so this file is unit-testable.
+ */
+
+function normalizeTeamName(value) {
+ return String(value || '').trim().toLowerCase();
+}
+
+/** Does a person's PCO team count as a band/stage team we display? `keywords` comes from admin config. */
+function isAllowedTeamName(value, keywords) {
+ const normalized = normalizeTeamName(value);
+ if (!normalized) return false;
+ return (keywords || []).some((kw) => {
+ const k = normalizeTeamName(kw);
+ return k && (normalized === k || normalized.includes(k));
+ });
+}
+
+function normalizeLabel(value) {
+ return String(value || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+/** Fold a single token to a canonical instrument/role word (drops mic/pack noise, pluralises down). */
+function normalizeToken(token) {
+ const normalized = String(token || '').trim().toLowerCase();
+ if (!normalized) return '';
+ if (/^\d+$/.test(normalized)) return '';
+ if (['mix', 'mic', 'slot', 'channel', 'ch', 'pack', 'wireless', 'input', 'headset'].includes(normalized)) return '';
+ if (['vox', 'vocal', 'vocalist', 'vocalists', 'vocals', 'singer', 'singers'].includes(normalized)) return 'vocals';
+ if (['keys', 'keyboard', 'keyboards'].includes(normalized)) return 'keys';
+ if (['drum', 'drums', 'drummer', 'drummers'].includes(normalized)) return 'drums';
+ if (['perc', 'percussionist', 'percussionists'].includes(normalized)) return 'percussion';
+ if (['gtr', 'guitar', 'guitars'].includes(normalized)) return 'guitar';
+ if (['electric', 'electricguitar'].includes(normalized)) return 'electric';
+ if (['acoustic', 'acousticguitar'].includes(normalized)) return 'acoustic';
+ if (['b', 'bandleader', 'leader', 'lead'].includes(normalized)) return normalized === 'b' ? '' : 'leader';
+ if (['worship', 'calltoworship'].includes(normalized)) return normalized === 'calltoworship' ? 'call' : normalized;
+ if (normalized.endsWith('s') && normalized.length > 4) return normalized.slice(0, -1);
+ return normalized;
+}
+
+function labelTokens(value) {
+ const normalized = normalizeLabel(value);
+ if (!normalized) return [];
+ const tokens = normalized.split(' ').map((t) => normalizeToken(t)).filter(Boolean);
+ if (!tokens.length && normalized) return [normalized];
+ return [...new Set(tokens)];
+}
+
+/**
+ * The role part of a slot label to match on: the bracketed content when present
+ * ("[VOX 2] MIX 2" → "VOX 2, "), else the whole label ("STAGE MANAGER"). This
+ * drops the "MIX n / MC n / HEADSET" mic-assignment noise so a mic number can't
+ * be mistaken for a position number.
+ */
+function slotRoleText(label) {
+ const brackets = String(label || '').match(/\[([^\]]*)\]/g);
+ if (brackets && brackets.length) return brackets.map((b) => b.slice(1, -1)).join(', ');
+ return String(label || '');
+}
+
+/** The set of standalone numbers in a label ("vox 2" → {"2"}). */
+function numbersIn(value) {
+ return new Set(normalizeLabel(value).split(' ').filter((t) => /^\d+$/.test(t)));
+}
+
+/** 0–~140 similarity between a slot label and a PCO position name; higher is a better fit. */
+function scoreMatch(slotLabel, positionLabel) {
+ const slotNormalized = normalizeLabel(slotLabel);
+ const positionNormalized = normalizeLabel(positionLabel);
+ if (!slotNormalized || !positionNormalized) return 0;
+ // A numbered role only matches the same number: "Vox 3" must not take "Vox 2".
+ const slotNums = numbersIn(slotLabel);
+ const posNums = numbersIn(positionLabel);
+ if (slotNums.size && posNums.size && ![...posNums].some((n) => slotNums.has(n))) return 0;
+ if (slotNormalized === positionNormalized) return 120;
+ if (slotNormalized.includes(positionNormalized) || positionNormalized.includes(slotNormalized)) return 95;
+
+ const slotTokens = labelTokens(slotLabel);
+ const positionTokens = labelTokens(positionLabel);
+ if (!slotTokens.length || !positionTokens.length) return 0;
+
+ const positionSet = new Set(positionTokens);
+ const slotSet = new Set(slotTokens);
+ const overlap = slotTokens.filter((t) => positionSet.has(t));
+ if (!overlap.length) return 0;
+
+ const overlapCount = overlap.length;
+ const slotCoverage = overlapCount / slotSet.size;
+ const positionCoverage = overlapCount / positionSet.size;
+ const sharedPhrase = overlap.join(' ');
+
+ let score = Math.round(slotCoverage * 70 + positionCoverage * 20 + overlapCount * 4);
+ if (slotCoverage === 1) score += 18;
+ if (positionCoverage === 1) score += 10;
+ if (sharedPhrase && slotNormalized.includes(sharedPhrase) && positionNormalized.includes(sharedPhrase)) score += 8;
+ return score;
+}
+
+/**
+ * Assign scheduled members to the ordered slot list. Greedy by best score, each
+ * member used once. Returns an array aligned to `slots`, each entry the chosen
+ * member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
+ */
+const MIN_SCORE = 60;
+function assignMembersToSlots(slots, members) {
+ const pool = members.slice();
+ return slots.map((slotLabel) => {
+ const role = slotRoleText(slotLabel);
+ let bestIndex = -1;
+ let bestScore = MIN_SCORE - 1;
+ for (let i = 0; i < pool.length; i += 1) {
+ const score = scoreMatch(role, pool[i].label);
+ if (score > bestScore) { bestScore = score; bestIndex = i; }
+ }
+ if (bestIndex < 0) return null;
+ return pool.splice(bestIndex, 1)[0];
+ });
+}
+
+/**
+ * Best library photo for a Planning Center person, matched by name so Sync can use the folder
+ * photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
+ * preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
+ * match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
+ * `entries` is [{ ...whatever, name }].
+ */
+function bestLibraryMatch(pcoName, entries) {
+ const target = normalizeLabel(pcoName);
+ if (!target) return null;
+ const T = target.split(' ').filter(Boolean);
+ const scoreOf = (name) => {
+ const ln = normalizeLabel(name);
+ if (!ln) return 0;
+ if (ln === target) return 100; // exact full name
+ const L = ln.split(' ').filter(Boolean);
+ if (!L.length || !T.length) return 0;
+ // Align the library name against a leading run of the person's name, token by token.
+ // Every library token must equal the person's token in that position — except the LAST
+ // library token, which may be an initial/prefix ("m" of "Moniz"). This makes "Brian M"
+ // match "Brian Moniz" and "Alex" match "Alex Gaitan", but NOT "Alex" → "Alexander Zinni".
+ let ok = true; let exact = 0; let usedPrefix = false;
+ for (let k = 0; k < L.length; k += 1) {
+ const lt = L[k]; const tt = T[k];
+ if (!tt) { ok = false; break; }
+ if (lt === tt) { exact += 1; continue; }
+ if (k === L.length - 1 && tt.startsWith(lt)) { usedPrefix = true; continue; }
+ ok = false; break;
+ }
+ if (ok) {
+ if (L.length >= 2) return usedPrefix ? 86 : 92; // "brian m" / "alex gaitan"
+ if (exact === 1) return 90; // first name matches exactly ("alex")
+ if (usedPrefix) return 45; // "alex" vs "alexander" — too ambiguous
+ }
+ // Otherwise: a library surname that appears anywhere in the person's name.
+ const last = L[L.length - 1];
+ if (last && last.length >= 3 && T.includes(last)) return 78;
+ return 0;
+ };
+ let best = null;
+ let bestScore = 0;
+ let tie = false;
+ for (const e of entries) {
+ const s = scoreOf(e.name);
+ if (s > bestScore) { bestScore = s; best = e; tie = false; }
+ else if (s === bestScore && s > 0) tie = true;
+ }
+ if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
+ return best;
+}
+
+module.exports = {
+ normalizeTeamName,
+ isAllowedTeamName,
+ bestLibraryMatch,
+ normalizeLabel,
+ normalizeToken,
+ labelTokens,
+ slotRoleText,
+ numbersIn,
+ scoreMatch,
+ assignMembersToSlots,
+ MIN_SCORE,
+};
diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js
new file mode 100644
index 0000000..516574c
--- /dev/null
+++ b/modules/band-lineup/matching.test.js
@@ -0,0 +1,67 @@
+'use strict';
+const assert = require('assert');
+const { assignMembersToSlots, isAllowedTeamName, slotRoleText, scoreMatch } = require('./matching');
+
+const SLOTS = [
+ '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4',
+ '[GUITAR, LEAD] MIX 5', '[BASS] MIX 6', '[VOX 5] MIX 7', '[PIANO]', '[DRUMS]',
+ '[KEYS 2]', '[CALL TO WORSHIP] MC2', 'STAGE MANAGER', '[MC] MC1', '[SPEAKER] HEADSET',
+];
+const M = (label) => ({ label, name: label });
+const members = [
+ M('Vox 3'), M('Vox 2'), M('Vox 1'), M('Guitar / Lead'), M('Bass'), M('Piano'), M('Drums'), M('MC'), M('Keys 2'),
+];
+const out = assignMembersToSlots(SLOTS, members).map((m) => (m ? m.label : null));
+
+// Numbered vocals land in the RIGHT slot (this is the swap bug we fixed).
+assert.strictEqual(out[0], 'Vox 1', 'slot 0 → Vox 1');
+assert.strictEqual(out[1], 'Vox 2', 'slot 1 → Vox 2');
+assert.strictEqual(out[2], 'Vox 3', 'slot 2 → Vox 3');
+assert.strictEqual(out[3], null, 'slot 3 (Vox 4) unfilled — nobody scheduled');
+assert.strictEqual(out[4], 'Guitar / Lead', 'slot 4 → Guitar/Lead');
+assert.strictEqual(out[5], 'Bass', 'slot 5 → Bass');
+assert.strictEqual(out[6], null, 'slot 6 (Vox 5) unfilled');
+assert.strictEqual(out[7], 'Piano', 'slot 7 → Piano');
+assert.strictEqual(out[8], 'Drums', 'slot 8 → Drums');
+assert.strictEqual(out[9], 'Keys 2', 'slot 9 → Keys 2');
+assert.strictEqual(out[12], 'MC', 'slot 12 → MC');
+
+// A different number never matches.
+assert.strictEqual(scoreMatch(slotRoleText('[VOX 2] MIX 2'), 'Vox 3'), 0, 'Vox 3 must not match Vox 2 slot');
+// Mic number no longer pollutes: Vox 7 must not grab "[VOX 5] MIX 7".
+assert.strictEqual(scoreMatch(slotRoleText('[VOX 5] MIX 7'), 'Vox 7'), 0, 'Vox 7 must not match the MIX 7 slot');
+
+// Team allow-list (singular stems match plurals/variants via substring).
+assert.ok(isAllowedTeamName('Band', ['band', 'vocal']));
+assert.ok(isAllowedTeamName('Vocals', ['band', 'vocal']));
+assert.ok(isAllowedTeamName('Vocal Team', ['band', 'vocal']));
+assert.ok(!isAllowedTeamName('Video', ['band', 'vocal', 'production']));
+
+console.log('matching.test.js: all assertions passed ✓');
+console.log('assignment:', JSON.stringify(out));
+
+// --- bestLibraryMatch: folder-photo → PCO person by name ---
+{
+ const { bestLibraryMatch } = require('./matching');
+ const name = (n) => (e) => (e ? e.name : null);
+ const lib = [
+ { id: 'a', name: 'Alex' }, { id: 'g', name: 'AlexG' }, { id: 'bm', name: 'Brian M' },
+ { id: 'c', name: 'Courtney' }, { id: 'gn', name: 'Gaitan' }, { id: 'wes', name: 'Wes' },
+ ];
+ const pick = (n, list = lib) => { const e = bestLibraryMatch(n, list); return e ? e.name : null; };
+ assert.strictEqual(pick('Alex Gaitan'), 'Alex', 'first name matches "Alex"');
+ assert.strictEqual(pick('Brian Moniz'), 'Brian M', '"Brian M" matches "Brian Moniz"');
+ assert.strictEqual(pick('Courtney Maranda'), 'Courtney', 'first name matches');
+ assert.strictEqual(pick('Alexander Zinni'), null, '"Alex" must NOT grab "Alexander Zinni"');
+ assert.strictEqual(pick('Wes Carter'), 'Wes', 'first name matches "Wes"');
+ // surname-named photo
+ assert.strictEqual(pick('Someone Gaitan'), 'Gaitan', 'surname photo matches by last name');
+ // two people share a first name + only a first-name photo → ambiguous only if two matches tie;
+ // here a single "Alex" photo still maps to whoever's first name is exactly Alex.
+ assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
+ // exact full name beats a first-name photo
+ assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
+ // tie → no match (two identical-scoring photos)
+ assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
+ console.log('bestLibraryMatch: all assertions passed ✓');
+}
diff --git a/modules/band-lineup/module.json b/modules/band-lineup/module.json
new file mode 100644
index 0000000..c144f92
--- /dev/null
+++ b/modules/band-lineup/module.json
@@ -0,0 +1,154 @@
+{
+ "id": "band-lineup",
+ "name": "Band Lineup",
+ "version": "1.11.0",
+ "proddash": ">=1.4.0",
+ "description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
+ "client": "client.js",
+ "server": "server.js",
+ "style": "style.css",
+ "minSize": {
+ "w": 4,
+ "h": 3
+ },
+ "defaultSize": {
+ "w": 8,
+ "h": 5
+ },
+ "configGroups": {
+ "Planning Center credentials": {
+ "collapsed": "whenSet",
+ "help": "Create a Personal Access Token at api.planningcenteronline.com and paste its Client ID + Secret here, or paste an OAuth 2.0 access token instead. Reading photos and birthdays needs People access on the token. Saved secrets never reach browsers."
+ },
+ "Stage": {
+ "help": "One mic/pack label per line, in the order they sit on stage. Scheduled people are matched into these by their Planning Center position (e.g. a line '[VOX 2] MIX 2' takes whoever is scheduled on Vox 2). Blank lines are empty spots."
+ },
+ "Photo library": {
+ "help": "Paste a Google Drive folder share link (set the folder to 'Anyone with the link → Viewer'). Every image is imported (file name → person's name) and kept in sync — add or remove photos in Drive and the board follows. A local folder path also works. If Drive can't be read, the board falls back to Planning Center photos."
+ }
+ },
+ "configSchema": {
+ "appId": {
+ "type": "string",
+ "label": "Client ID",
+ "default": "",
+ "group": "Planning Center credentials"
+ },
+ "secret": {
+ "type": "password",
+ "label": "Secret",
+ "default": "",
+ "group": "Planning Center credentials"
+ },
+ "accessToken": {
+ "type": "password",
+ "label": "OAuth 2.0 access token (instead of Client ID + Secret)",
+ "default": "",
+ "group": "Planning Center credentials"
+ },
+ "serviceTypeId": {
+ "type": "select",
+ "label": "Service type",
+ "default": "",
+ "optionsRoute": "/service-types"
+ },
+ "planId": {
+ "type": "select",
+ "label": "Plan",
+ "default": "",
+ "options": [
+ {
+ "value": "",
+ "label": "Automatic — today's plan, else the next upcoming"
+ }
+ ],
+ "optionsRoute": "/plans",
+ "optionsDependsOn": "serviceTypeId"
+ },
+ "teams": {
+ "type": "text",
+ "label": "Teams to include",
+ "default": "Band\nWorship\nVocal\nProduction\nCommunication",
+ "help": "One team name (or keyword) per line. A person on any matching Planning Center team is eligible for the board."
+ },
+ "slots": {
+ "type": "text",
+ "label": "Stage slots",
+ "default": "[LEADER, VOX 1, ACOUSTIC] MIX 1\n[VOX 2] MIX 2\n[VOX 3] MIX 3\n[VOX 4] MIX 4\n[GUITAR, LEAD] MIX 5\n[BASS] MIX 6\n[VOX 5] MIX 7\n[PIANO]\n[DRUMS]\n[KEYS 2]\n[CALL TO WORSHIP] MC2\nSTAGE MANAGER\n[MC] MC1\n[SPEAKER] HEADSET",
+ "group": "Stage"
+ },
+ "columns": {
+ "type": "number",
+ "label": "Columns",
+ "default": 7,
+ "group": "Stage",
+ "help": "Default slots per row (each screen can still adjust with the − / + buttons in its title bar)."
+ },
+ "photoFolder": {
+ "type": "string",
+ "label": "Google Drive folder link",
+ "default": "",
+ "group": "Photo library",
+ "help": "e.g. https://drive.google.com/drive/folders/… (a local folder path also works). Leave blank to use Planning Center photos only."
+ }
+ },
+ "instanceGroups": {
+ "Birthday": {
+ "toggle": "birthdays"
+ }
+ },
+ "instanceSchema": {
+ "showNames": {
+ "type": "boolean",
+ "label": "Names",
+ "default": true
+ },
+ "showLabels": {
+ "type": "boolean",
+ "label": "Mic labels",
+ "default": true
+ },
+ "parallax": {
+ "type": "boolean",
+ "label": "Parallax motion",
+ "default": true
+ },
+ "birthdays": {
+ "type": "switch",
+ "label": "Birthday confetti",
+ "default": true,
+ "group": "Birthday"
+ },
+ "birthdayIntensity": {
+ "type": "select",
+ "label": "Intensity",
+ "default": "subtle",
+ "group": "Birthday",
+ "showWhen": "birthdays",
+ "options": [
+ {
+ "value": "subtle",
+ "label": "Subtle"
+ },
+ {
+ "value": "full",
+ "label": "Full"
+ }
+ ]
+ },
+ "birthdayTest": {
+ "type": "select",
+ "label": "Force on (test)",
+ "default": "",
+ "group": "Birthday",
+ "showWhen": "birthdays",
+ "options": [
+ {
+ "value": "",
+ "label": "Off"
+ }
+ ],
+ "optionsRoute": "/people"
+ }
+ }
+}
diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js
new file mode 100644
index 0000000..4141122
--- /dev/null
+++ b/modules/band-lineup/server.js
@@ -0,0 +1,764 @@
+'use strict';
+
+/**
+ * Band Lineup — server part.
+ *
+ * Owns the shared stage board and the uploaded-photo library, plus the one
+ * Planning Center connection. The board is server-side and global: whoever
+ * edits it (drag a library photo onto a slot, rename, clear) changes it for
+ * every Band Lineup tile on every screen, over SSE. Planning Center sync is an
+ * optional auto-fill — it only fills slots nobody has placed by hand. Browsers
+ * never talk to Planning Center directly.
+ *
+ * GET /state → { state } (board + library; instant paint)
+ * GET /stream → SSE `state` events (change-detected, heartbeat)
+ * GET /library → { people } (the drag-in photo library)
+ * GET /service-types → { options } (admin "Service type" select)
+ * GET /plans → { options } (admin "Plan" select)
+ * GET /people → { options } (gear "Force birthday" select)
+ * GET /photo/ → image bytes (library + cached PCO photos)
+ * POST /library/ → rename / set birthday / replace photo (raw image body; ?name=&birthday=)
+ * POST /library//delete→ remove from the library and any slot
+ * POST /assign → ?slot=&personId= place a library person in a slot
+ * POST /move → ?from=&to= move or swap one slot's occupant
+ * POST /slot//name → ?name= rename the occupant (or make a name-only spot)
+ * POST /slot//clear → empty the slot
+ * POST /choir → ?on=1|0 toggle the extra CHOIR slot (shared)
+ * POST /sync → run a Planning Center + photo-folder sync now
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const crypto = require('crypto');
+
+const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
+
+const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
+const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
+const SERVICES = '/services/v2';
+const PEOPLE = '/people/v2';
+
+const REQUEST_TIMEOUT_MS = 12000;
+const PHOTO_TIMEOUT_MS = 15000;
+const MIN_POLL_S = 10;
+const RETRY_MS = 15000;
+const HEARTBEAT_MS = 15000;
+const MODULE_ID = 'band-lineup';
+const MAX_UPLOAD = 20 * 1024 * 1024;
+
+let current = null; // set by init(), read by routes; rebuilt together on remount
+
+/* ── data directory (library + board + photo cache live here, never in the repo) ── */
+
+function resolveDataDir() {
+ const home = os.homedir();
+ const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
+ const root = path.join(__dirname, '..', '..');
+ if (fromEnv) return path.resolve(root, fromEnv);
+ if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
+ if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'ProdDash');
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
+}
+
+/* ── small helpers ─────────────────────────────────────────────────── */
+
+function lines(text) { return String(text || '').split('\n').map((s) => s.trim()); }
+function nonEmptyLines(text) { return lines(text).filter(Boolean); }
+function localYmd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
+function birthdayMmdd(raw) {
+ const s = String(raw || '').trim();
+ const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/);
+ if (full) return `${full[1]}-${full[2]}`;
+ return /^\d{2}-\d{2}$/.test(s) ? s : '';
+}
+function birthdayWeekSet(now = new Date()) {
+ const daysSinceWed = (now.getDay() - 3 + 7) % 7;
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
+ const set = new Set();
+ for (let i = 0; i < 7; i += 1) {
+ const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
+ set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
+ }
+ return set;
+}
+function extFromContentType(ct) {
+ const t = String(ct || '').toLowerCase();
+ if (t.includes('png')) return 'png';
+ if (t.includes('webp')) return 'webp';
+ if (t.includes('gif')) return 'gif';
+ return 'jpg';
+}
+function contentTypeForFile(file) {
+ const e = path.extname(file).toLowerCase();
+ if (e === '.png') return 'image/png';
+ if (e === '.webp') return 'image/webp';
+ if (e === '.gif') return 'image/gif';
+ return 'image/jpeg';
+}
+function json(res, status, body) {
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(body));
+}
+function readJsonFile(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) || fallback; } catch { return fallback; } }
+function newId() { return crypto.randomBytes(6).toString('hex'); }
+function photoUrlFor(file) { return file ? `/api/modules/${MODULE_ID}/photo/${encodeURIComponent(file)}` : ''; }
+
+/* ── 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');
+ const configured = Boolean(auth);
+
+ 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) { const e = new Error(`Planning Center ${response.status}`); e.status = response.status; throw e; }
+ const text = await response.text();
+ return text ? JSON.parse(text) : {};
+ }
+ async function getAll(target, params) {
+ const data = []; const included = []; let next = null;
+ let query = { per_page: 100, ...(params || {}) }; let guard = 0;
+ do {
+ const body = next ? await get(next) : await get(target, query);
+ if (Array.isArray(body.data)) data.push(...body.data);
+ if (Array.isArray(body.included)) included.push(...body.included);
+ next = body?.links?.next || null; query = null; guard += 1;
+ } while (next && guard < 50);
+ return { data, included };
+ }
+ return { configured, get, getAll };
+}
+
+/* ── Google Drive (public "anyone with the link" folder, no API key) ── */
+
+const DRIVE_TIMEOUT_MS = 30000;
+const DRIVE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36';
+
+/** Pull the folder id out of a Drive share link (or '' if it isn't a Drive folder link). */
+function parseDriveFolderId(s) {
+ const str = String(s || '');
+ if (!/drive\.google\.com/i.test(str)) return '';
+ const m = /\/folders\/([A-Za-z0-9_-]{10,})/.exec(str) || /[?&]id=([A-Za-z0-9_-]{10,})/.exec(str);
+ return m ? m[1] : '';
+}
+
+/** List a public folder's files via Drive's embedded folder view — [{ id, name }]. */
+async function driveList(folderId) {
+ const resp = await fetch(`https://drive.google.com/embeddedfolderview?id=${encodeURIComponent(folderId)}#list`, {
+ headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS),
+ });
+ if (!resp.ok) throw new Error(`Drive folder list ${resp.status}`);
+ const html = await resp.text();
+ const out = []; const seen = new Set();
+ const re = /id="entry-([A-Za-z0-9_-]{20,})"[\s\S]*?flip-entry-title[^>]*>([^<]+) (choirOn ? slots.concat([CHOIR_LABEL]) : slots);
+
+ const pco = createPcoClient(config);
+
+ // Persisted, shared editor state.
+ let library = readJsonFile(libraryPath, {}); // { id: { name, file, birthday } }
+ let board = readJsonFile(boardPath, {}); // { slotIndex: { personId, name } }
+ function saveLibrary() { try { fs.writeFileSync(libraryPath, JSON.stringify(library, null, 2)); } catch (err) { log(`save library failed: ${err?.message || err}`); } }
+ function saveBoard() { try { fs.writeFileSync(boardPath, JSON.stringify(board, null, 2)); } catch (err) { log(`save board failed: ${err?.message || err}`); } }
+
+ let latest = clearedState();
+ let lastFrame = '';
+ let celebrateTimer = null;
+ let drivePollTimer = null;
+ let watcher = null;
+ let watchDebounce = null;
+ let stopped = false;
+ let syncing = false;
+
+ function clearedState() {
+ return {
+ configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
+ reachable: false, lastError: '', lastFetched: 0, columns,
+ plan: null,
+ slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
+ library: [], choir: choirOn,
+ };
+ }
+
+ function libraryList() {
+ return Object.entries(library)
+ .map(([id, v]) => ({ id, name: v.name || '', photo: photoUrlFor(v.file || ''), birthday: v.birthday || '' }))
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
+ }
+
+ function broadcast() {
+ if (!streams.size) return;
+ let frame; try { frame = JSON.stringify(latest); } catch { return; }
+ if (frame === lastFrame) return;
+ lastFrame = frame;
+ const payload = `event: state\ndata: ${frame}\n\n`;
+ for (const res of streams) { try { res.write(payload); } catch { streams.delete(res); } }
+ }
+
+ async function listPlans(stId) {
+ const [future, past] = await Promise.all([
+ pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'future', order: 'sort_date', per_page: 6 }),
+ pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'past', order: '-sort_date', per_page: 2 }),
+ ]);
+ const rows = [...(future.data || []), ...(past.data || [])];
+ return rows.map((rec) => ({
+ id: String(rec.id), sortDate: Date.parse(rec.attributes?.sort_date || '') || 0,
+ title: String(rec.attributes?.title || rec.attributes?.series_title || '').trim(),
+ dates: String(rec.attributes?.dates || rec.attributes?.short_dates || '').trim(),
+ }));
+ }
+ function pickPlan(plans) {
+ if (planIdCfg) return plans.find((p) => p.id === planIdCfg) || { id: planIdCfg, title: '', dates: '' };
+ const todayY = localYmd(new Date());
+ const today = plans.find((p) => p.sortDate && localYmd(new Date(p.sortDate)) === todayY);
+ if (today) return today;
+ const now = Date.now();
+ const upcoming = plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate);
+ return upcoming[0] || plans.sort((a, b) => b.sortDate - a.sortDate)[0] || null;
+ }
+
+ async function cachePcoPhoto(personId, url) {
+ if (!url) return '';
+ const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 8);
+ const existing = fs.existsSync(photoDir) ? fs.readdirSync(photoDir).find((f) => f.startsWith(`pco-${personId}-${hash}.`)) : null;
+ if (existing) return existing;
+ try {
+ const resp = await fetch(url, { signal: AbortSignal.timeout(PHOTO_TIMEOUT_MS) });
+ if (!resp.ok) return '';
+ const buf = Buffer.from(await resp.arrayBuffer());
+ if (!buf.length) return '';
+ const file = `pco-${personId}-${hash}.${extFromContentType(resp.headers.get('content-type'))}`;
+ fs.writeFileSync(path.join(photoDir, file), buf);
+ return file;
+ } catch { return ''; }
+ }
+
+ /**
+ * Import a folder of photos into the library and keep it in sync: new files are added
+ * (filename → name), changed files refreshed, and files removed from the folder dropped
+ * from the library (and any slot they filled). Manually-uploaded photos (no folderKey) are
+ * left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
+ */
+ const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
+ const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
+ function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
+ function scanFolder() {
+ if (!photoFolder) return false;
+ let names; try { names = fs.readdirSync(photoFolder); } catch { return false; } // folder gone/unreadable
+ const files = names.filter((n) => IMG_RE.test(n));
+ const present = new Set(files);
+ const byKey = new Map();
+ for (const [id, v] of Object.entries(library)) if (v.folderKey) byKey.set(v.folderKey, id);
+ let changed = false;
+ for (const f of files) {
+ let mtime = 0; try { mtime = fs.statSync(path.join(photoFolder, f)).mtimeMs; } catch { continue; }
+ const id = byKey.get(f);
+ try {
+ if (id) {
+ const v = library[id];
+ if (v.mtime === mtime && v.file) continue; // unchanged
+ const dest = `lib-${id}.${extOf(f)}`;
+ fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
+ if (v.file && v.file !== dest) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
+ v.file = dest; v.mtime = mtime; changed = true; // keep any in-app rename/birthday
+ } else {
+ const nid = newId();
+ const dest = `lib-${nid}.${extOf(f)}`;
+ fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
+ library[nid] = { name: cleanNameFromFile(f), file: dest, birthday: '', folderKey: f, mtime };
+ changed = true;
+ }
+ } catch (err) { log(`folder import of ${f} failed: ${err?.message || err}`); }
+ }
+ for (const [id, v] of Object.entries(library)) {
+ if (v.folderKey && !present.has(v.folderKey)) {
+ if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
+ delete library[id];
+ for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
+ changed = true;
+ }
+ }
+ // De-duplicate: a hand-uploaded photo that shares a name with a folder photo is dropped
+ // (the folder wins), and any slot it filled repoints to the folder photo. Fixes the case
+ // where someone uploaded people by hand and then pointed at a folder of the same people.
+ const folderByName = new Map();
+ for (const [id, v] of Object.entries(library)) if (v.folderKey && v.name) folderByName.set(normName(v.name), id);
+ for (const [id, v] of Object.entries(library)) {
+ if (v.folderKey || !v.name) continue;
+ const keepId = folderByName.get(normName(v.name));
+ if (!keepId) continue;
+ if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
+ for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
+ delete library[id];
+ changed = true;
+ }
+ if (changed) { saveLibrary(); saveBoard(); }
+ return changed;
+ }
+
+ // Import a public Google Drive folder into the library (file name → name), keeping it in sync.
+ // Keyed by Drive file id (folderKey "drive:"). On a list failure we keep the last-good library.
+ async function scanDrive(folderId) {
+ let files;
+ try { files = await driveList(folderId); } catch (err) { log(`Drive list failed: ${err?.message || err}`); return false; }
+ files = files.filter((f) => IMG_RE.test(f.name));
+ const present = new Set(files.map((f) => `drive:${f.id}`));
+ const have = new Set();
+ for (const v of Object.values(library)) if (v.folderKey && v.folderKey.startsWith('drive:')) have.add(v.folderKey);
+ let changed = false;
+ for (const f of files) {
+ const key = `drive:${f.id}`;
+ if (have.has(key)) continue; // already imported (by Drive file id)
+ let img;
+ try { img = await driveFetchImage(f.id); } catch (err) { log(`Drive image ${f.name} failed: ${err?.message || err}`); continue; }
+ const nid = newId();
+ const dest = `lib-${nid}.${img.ext}`;
+ try { fs.writeFileSync(path.join(photoDir, dest), img.buf); library[nid] = { name: cleanNameFromFile(f.name), file: dest, birthday: '', folderKey: key }; changed = true; }
+ catch (err) { log(`write ${dest} failed: ${err?.message || err}`); }
+ }
+ for (const [id, v] of Object.entries(library)) {
+ if (v.folderKey && v.folderKey.startsWith('drive:') && !present.has(v.folderKey)) {
+ if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
+ delete library[id];
+ for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
+ changed = true;
+ }
+ }
+ // Drive photo wins over a same-named hand-upload (repoint any slot to it).
+ const driveByName = new Map();
+ for (const [id, v] of Object.entries(library)) if (v.folderKey && v.folderKey.startsWith('drive:') && v.name) driveByName.set(normName(v.name), id);
+ for (const [id, v] of Object.entries(library)) {
+ if ((v.folderKey && v.folderKey.startsWith('drive:')) || !v.name) continue;
+ const keepId = driveByName.get(normName(v.name));
+ if (!keepId) continue;
+ if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
+ for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
+ delete library[id]; changed = true;
+ }
+ if (changed) { saveLibrary(); saveBoard(); }
+ return changed;
+ }
+
+ // Import the configured photo source — a Drive folder link, or a local folder path.
+ async function syncPhotos() {
+ const fid = parseDriveFolderId(photoFolder);
+ if (fid) return scanDrive(fid);
+ if (photoFolder) return scanFolder();
+ return false;
+ }
+
+ function startWatch() {
+ if (parseDriveFolderId(photoFolder)) return; // Drive folders are polled, not fs.watched
+ if (!photoFolder) return;
+ try {
+ watcher = fs.watch(photoFolder, { persistent: false }, () => {
+ clearTimeout(watchDebounce);
+ watchDebounce = setTimeout(() => { if (!stopped && scanFolder()) rebuildLocal(); }, 800);
+ });
+ log(`watching photo folder ${photoFolder}`);
+ } catch (err) { log(`cannot watch photo folder: ${err?.message || err}`); }
+ }
+ function setChoir(on) { choirOn = Boolean(on); settings.choir = choirOn; saveSettings(); rebuildLocal(); }
+
+ // Remove cached PCO photos no longer referenced. Library files (lib-*) are never pruned here.
+ function prunePcoPhotos(keep) {
+ let files; try { files = fs.readdirSync(photoDir); } catch { return; }
+ for (const f of files) { if (f.startsWith('pco-') && !keep.has(f)) { try { fs.unlinkSync(path.join(photoDir, f)); } catch { /* ignore */ } } }
+ }
+
+ const normName = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
+
+ // Resolve one board slot to a display person (or null). The board is the source of truth —
+ // there is no live PCO overlay, so clearing a slot stays cleared until the next Sync. A PCO
+ // entry prefers a same-named library photo, so uploading a photo updates the board without a re-sync.
+ function resolveSlot(i, weekSet, keep) {
+ const e = board[String(i)];
+ if (!e) return null;
+ let name = '', file = '', birthday = '', manual = true, personId = '';
+ if (e.pco) {
+ // The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
+ manual = false; personId = e.pco.personId || '';
+ name = String(e.pco.name || '').trim();
+ file = e.pco.file || '';
+ birthday = e.pco.birthday || '';
+ } else if (e.personId) {
+ const lib = library[e.personId];
+ if (!lib) return null; // its library photo was deleted → slot is empty
+ personId = e.personId;
+ name = String(e.name || lib.name || '').trim();
+ file = lib.file || ''; birthday = lib.birthday || '';
+ } else if (e.name) {
+ name = String(e.name).trim();
+ } else return null;
+ if (file) keep.add(file);
+ return { name, photo: photoUrlFor(file), birthday, celebrating: Boolean(birthday) && weekSet.has(birthday), manual, personId };
+ }
+
+ // Repaint the display from the board (no Planning Center calls). Cheap — used after every edit
+ // and on a slow timer so a birthday week rolling over lights up on its own.
+ function renderBoard() {
+ const weekSet = birthdayWeekSet();
+ const keep = new Set();
+ const outSlots = effectiveSlots().map((label, i) => ({ index: i, label, person: resolveSlot(i, weekSet, keep) }));
+ for (const v of Object.values(library)) if (v.file) keep.add(v.file);
+ prunePcoPhotos(keep);
+ latest = {
+ configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
+ reachable: latest.reachable, lastError: latest.lastError || '', lastFetched: latest.lastFetched || 0,
+ columns, plan: latest.plan || null, slots: outSlots, library: libraryList(), choir: choirOn,
+ };
+ }
+
+ function rebuildLocal() { renderBoard(); broadcast(); }
+
+ // Pull the current plan from Planning Center and write matched people INTO the board. Fills only
+ // slots nobody placed by hand; drops PCO entries no longer scheduled; leaves manual placements be.
+ async function syncNow() {
+ if (syncing) return;
+ syncing = true;
+ try {
+ await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
+ if (pco.configured && serviceTypeId) {
+ const plan = pickPlan(await listPlans(serviceTypeId));
+ latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
+ if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
+ else {
+
+ const tm = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(plan.id)}/team_members`, { include: 'person,team' });
+ const byKey = new Map((tm.included || []).map((e) => [`${e.type}:${e.id}`, e]));
+ const members = []; const seen = new Set();
+ for (const m of tm.data || []) {
+ const status = String(m.attributes?.status || '').trim();
+ if (status === 'D' || /^declined$/i.test(status)) continue;
+ const personId = String(m.relationships?.person?.data?.id || '');
+ const team = byKey.get(`Team:${String(m.relationships?.team?.data?.id || '')}`);
+ if (!isAllowedTeamName(String(team?.attributes?.name || ''), teamKeywords)) continue;
+ const person = byKey.get(`Person:${personId}`);
+ members.push({
+ personId,
+ name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
+ label: String(m.attributes?.team_position_name || '').trim(),
+ photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
+ });
+ if (personId) seen.add(personId);
+ }
+ const bdays = new Map();
+ for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
+ for (const m of members) m.birthday = bdays.get(m.personId) || '';
+
+ const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
+
+ // Slots a person is sitting in by hand — don't fill them, and don't place that same person twice.
+ const eff = effectiveSlots();
+ const openIdx = []; const manualNames = new Set();
+ for (let i = 0; i < eff.length; i += 1) {
+ const e = board[String(i)];
+ if (e && (e.personId || e.name)) {
+ const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
+ if (nm) manualNames.add(normName(nm));
+ } else openIdx.push(i);
+ }
+ const pool = members.filter((m) => !manualNames.has(normName(m.name)));
+ const assigned = assignMembersToSlots(openIdx.map((i) => eff[i]), pool);
+
+ for (let k = 0; k < openIdx.length; k += 1) {
+ const i = openIdx[k]; const m = assigned[k];
+ if (m) {
+ const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
+ const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
+ board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
+ } else if (board[String(i)] && board[String(i)].pco) {
+ delete board[String(i)]; // was synced, no longer on the plan
+ }
+ }
+ saveBoard();
+ latest.reachable = true; latest.lastError = ''; latest.lastFetched = Date.now();
+ log(`synced ${members.length} scheduled from ${plan.dates || plan.id}`);
+ } // end else (plan found)
+ } // end if (Planning Center configured)
+ } catch (err) {
+ latest.reachable = false; latest.lastError = err?.message || String(err);
+ log(`sync failed: ${err?.message || err}`);
+ } finally {
+ syncing = false;
+ renderBoard(); broadcast();
+ }
+ }
+
+ const heartbeat = setInterval(() => { for (const res of streams) { try { res.write(': ping\n\n'); } catch { streams.delete(res); } } }, HEARTBEAT_MS);
+ heartbeat.unref?.();
+ // Repaint on a slow timer so a birthday week (Wed→Tue) rolling over lights up unattended.
+ celebrateTimer = setInterval(() => { if (!stopped) rebuildLocal(); }, 15 * 60 * 1000);
+ celebrateTimer.unref?.();
+ // A Drive folder can't be fs.watched — re-import it on a slow timer so photos added/removed
+ // in Drive show up without a manual Sync.
+ drivePollTimer = setInterval(() => {
+ if (stopped || !parseDriveFolderId(photoFolder)) return;
+ syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch(() => {});
+ }, 5 * 60 * 1000);
+ drivePollTimer.unref?.();
+
+ if (pco.configured && serviceTypeId) log('ready — press Sync to load the plan from Planning Center');
+ else log('manual board (add Planning Center credentials + a service type in /admin to enable Sync)');
+ rebuildLocal(); // paint the saved board immediately
+ startWatch(); // local folders keep in sync via fs.watch (Drive folders are polled)
+ syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch((err) => log(`initial photo import failed: ${err?.message || err}`));
+
+ current = {
+ streams, getLatest: () => latest, pco, listPlans, photoDir,
+ library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
+ photoUrlFor, libraryList,
+ setChoir, choirOn: () => choirOn,
+ };
+
+ return {
+ stop() {
+ stopped = true; if (celebrateTimer) clearInterval(celebrateTimer); if (drivePollTimer) clearInterval(drivePollTimer); clearInterval(heartbeat);
+ if (watcher) { try { watcher.close(); } catch { /* gone */ } watcher = null; }
+ if (watchDebounce) clearTimeout(watchDebounce);
+ for (const res of streams) { try { res.end(); } catch { /* gone */ } }
+ streams.clear(); if (current && current.streams === streams) current = null;
+ },
+ health() {
+ if (!pco.configured || !serviceTypeId) return { status: 'ok', message: 'Manual board (add Planning Center to enable Sync)' };
+ if (latest.lastError) return { status: 'error', message: latest.lastError };
+ if (latest.reachable) return { status: 'ok', message: latest.plan?.dates ? `Synced: ${latest.plan.dates}` : 'Synced' };
+ return { status: 'ok', message: 'Ready — press Sync to load the plan' };
+ },
+ };
+ },
+
+ routes({ log }) {
+ // Read an image request body (raw bytes) with a size cap.
+ function readBody(req, cb) {
+ const chunks = []; let size = 0; let bad = false;
+ req.on('data', (c) => { size += c.length; if (size > MAX_UPLOAD) { bad = true; req.destroy(); } else chunks.push(c); });
+ req.on('end', () => cb(bad ? null : Buffer.concat(chunks)));
+ req.on('error', () => cb(null));
+ }
+
+ return {
+ 'GET /state': (req, res) => json(res, 200, { state: current ? current.getLatest() : null }),
+
+ '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');
+ res.write(`event: state\ndata: ${JSON.stringify(current ? current.getLatest() : null)}\n\n`);
+ if (!current) return void res.end();
+ try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
+ current.streams.add(res);
+ req.on('close', () => current && current.streams.delete(res));
+ },
+
+ 'GET /library': (req, res) => json(res, 200, { people: current ? current.libraryList() : [] }),
+
+ // Update a library entry: ?name=&birthday= and/or a replacement image body. /library//delete removes it.
+ 'POST /library/*': (req, res) => {
+ if (!current) return json(res, 503, { error: 'Module not running.' });
+ const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
+ const del = /\/delete$/.test(rest);
+ const id = rest.replace(/\/delete$/, '').trim();
+ const entry = current.library[id];
+ if (!entry) return json(res, 404, { error: 'No such library photo.' });
+ if (del) {
+ if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
+ delete current.library[id];
+ for (const [slot, v] of Object.entries(current.board)) if (v && v.personId === id) delete current.board[slot];
+ current.saveLibrary(); current.saveBoard(); current.rebuildLocal();
+ return json(res, 200, { ok: true });
+ }
+ const q = new URLSearchParams(req.search || '');
+ readBody(req, (buf) => {
+ if (q.has('name')) entry.name = String(q.get('name') || '').trim();
+ if (q.has('birthday')) entry.birthday = birthdayMmdd(q.get('birthday'));
+ if (buf && buf.length) {
+ if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
+ const file = `lib-${id}.${extFromContentType(req.headers['content-type'])}`;
+ try { fs.writeFileSync(path.join(current.photoDir, file), buf); entry.file = file; } catch (err) { return json(res, 500, { error: `write failed: ${err?.message || err}` }); }
+ }
+ current.saveLibrary(); current.rebuildLocal();
+ json(res, 200, { ok: true });
+ });
+ },
+
+ // Toggle the extra CHOIR slot on/off (?on=1|0, or omit to flip). Shared across all screens.
+ 'POST /choir': (req, res) => {
+ if (!current) return json(res, 503, { error: 'Module not running.' });
+ const p = new URLSearchParams(req.search || '');
+ const on = p.has('on') ? (p.get('on') === '1' || p.get('on') === 'true') : !current.choirOn();
+ current.setChoir(on);
+ json(res, 200, { ok: true, choir: on });
+ },
+
+ // Move (or swap) one slot's occupant to another slot: ?from=&to=.
+ 'POST /move': (req, res) => {
+ if (!current) return json(res, 503, { error: 'Module not running.' });
+ const p = new URLSearchParams(req.search || '');
+ const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
+ if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
+ const a = current.board[from]; const b = current.board[to];
+ if (a === undefined) delete current.board[to]; else current.board[to] = a;
+ if (b === undefined) delete current.board[from]; else current.board[from] = b;
+ current.saveBoard(); current.rebuildLocal();
+ json(res, 200, { ok: true });
+ },
+
+ // Place a library person in a slot: ?slot=&personId= (empty personId clears the slot).
+ 'POST /assign': (req, res) => {
+ if (!current) return json(res, 503, { error: 'Module not running.' });
+ const q = new URLSearchParams(req.search || '');
+ const slot = String(q.get('slot') || '').trim();
+ const personId = String(q.get('personId') || '').trim();
+ if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
+ if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
+ if (personId) current.board[slot] = { personId, name: '' };
+ else delete current.board[slot];
+ current.saveBoard(); current.rebuildLocal();
+ json(res, 200, { ok: true });
+ },
+
+ // Rename a slot's occupant, or make a name-only spot (no photo). /slot//clear empties it.
+ 'POST /slot/*': (req, res) => {
+ if (!current) return json(res, 503, { error: 'Module not running.' });
+ const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
+ const clearing = /\/clear$/.test(rest);
+ const index = rest.replace(/\/(clear|name)$/, '').trim();
+ if (!/^\d+$/.test(index)) return json(res, 400, { error: 'Bad slot.' });
+ if (clearing) { delete current.board[index]; current.saveBoard(); current.rebuildLocal(); return json(res, 200, { ok: true }); }
+ const q = new URLSearchParams(req.search || '');
+ const name = String(q.get('name') || '').trim();
+ const existing = current.board[index] || { personId: '', name: '' };
+ existing.name = name;
+ if (!existing.personId && !name) delete current.board[index];
+ else current.board[index] = existing;
+ current.saveBoard(); current.rebuildLocal();
+ json(res, 200, { ok: true });
+ },
+
+ // Force an immediate Planning Center refresh (the "Sync" button). Auto-fill also
+ // runs on its own timer; this just does it now.
+ 'POST /sync': (req, res) => {
+ if (!current) return json(res, 503, { error: 'Module not running.' });
+ current.refresh(); // scans the photos folder + pulls the plan if Planning Center is configured
+ json(res, 200, { ok: true });
+ },
+
+ 'GET /people': (req, res) => {
+ const state = current ? current.getLatest() : null;
+ const options = [{ value: '', label: 'Off' }];
+ for (const slot of state?.slots || []) if (slot.person && slot.person.name) options.push({ value: String(slot.index), label: slot.person.name });
+ json(res, 200, { options });
+ },
+
+ 'GET /photo/*': (req, res) => {
+ if (!current) return void res.writeHead(404).end();
+ const name = path.basename(decodeURIComponent(req.wildcard || '').replace(/^\/+/, ''));
+ if (!name || name.includes('..')) return void res.writeHead(400).end();
+ fs.readFile(path.join(current.photoDir, name), (err, buf) => {
+ if (err) return void res.writeHead(404).end();
+ res.writeHead(200, { 'Content-Type': contentTypeForFile(name), 'Cache-Control': 'public, max-age=60' });
+ res.end(buf);
+ });
+ },
+
+ 'GET /service-types': async (req, res) => {
+ if (!current) return json(res, 503, { options: [], error: 'Module not running.' });
+ if (!current.pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
+ try {
+ const [folderBody, stBody] = await Promise.all([current.pco.getAll(`${SERVICES}/folders`), current.pco.getAll(`${SERVICES}/service_types`)]);
+ const folders = new Map();
+ for (const f of folderBody.data || []) folders.set(String(f.id), { name: String(f.attributes?.name || 'Folder'), parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '' });
+ const pathOf = (parentId) => { const names = []; let id = parentId; const seen = new Set(); while (id && folders.has(id) && !seen.has(id)) { seen.add(id); names.unshift(folders.get(id).name); id = folders.get(id).parentId; } return names.join(' › '); };
+ const options = (stBody.data || []).map((st) => ({ value: String(st.id), label: String(st.attributes?.name || 'Service type'), group: pathOf(st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '') })).sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
+ json(res, 200, { options });
+ } catch (err) { log(`service type list failed: ${err?.message || err}`); json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) }); }
+ },
+
+ 'GET /plans': async (req, res) => {
+ if (!current) return json(res, 503, { options: [] });
+ const params = new URLSearchParams(req.search || '');
+ const stId = String(params.get('serviceTypeId') || '').trim();
+ const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
+ if (!current.pco.configured || !stId) return json(res, 200, { options: [auto] });
+ try {
+ const plans = await current.listPlans(stId); const now = Date.now();
+ const ordered = [...plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate), ...plans.filter((p) => p.sortDate < now - 12 * 3600000).sort((a, b) => b.sortDate - a.sortDate)];
+ json(res, 200, { options: [auto, ...ordered.map((p) => ({ value: p.id, label: [p.dates, p.title].filter(Boolean).join(' — ') || p.id }))] });
+ } catch (err) { json(res, 502, { options: [auto], error: err?.message || String(err) }); }
+ },
+ };
+ },
+};
diff --git a/modules/band-lineup/style.css b/modules/band-lineup/style.css
new file mode 100644
index 0000000..68fe5bf
--- /dev/null
+++ b/modules/band-lineup/style.css
@@ -0,0 +1,232 @@
+/* Band Lineup — scoped to the tile, themed only through the shell's variables. */
+
+[data-module="band-lineup"] .bl-board {
+ height: 100%;
+ overflow: hidden;
+ display: grid;
+ grid-template-columns: repeat(var(--bl-cols, 7), minmax(0, 1fr));
+ grid-template-rows: repeat(var(--bl-rows, 2), minmax(0, 1fr));
+ gap: 6px;
+ padding: 6px;
+}
+
+[data-module="band-lineup"] .bl-message {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ color: var(--muted);
+ padding: 16px;
+ font-size: 0.95em;
+}
+
+[data-module="band-lineup"] .bl-slot {
+ min-width: 0;
+ min-height: 0;
+}
+
+[data-module="band-lineup"] .bl-tile {
+ display: flex;
+ flex-direction: column;
+ border-radius: 10px;
+ overflow: hidden;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ height: 100%;
+ position: relative;
+}
+
+[data-module="band-lineup"] .bl-photo {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ background: color-mix(in srgb, var(--bg) 70%, black);
+}
+
+[data-module="band-lineup"] .bl-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center top;
+ display: block;
+ transition: transform 0.12s ease-out;
+ will-change: transform;
+}
+
+[data-module="band-lineup"] .bl-initials {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2.4em;
+ font-weight: 800;
+ color: color-mix(in srgb, var(--muted) 70%, transparent);
+ letter-spacing: 0.04em;
+}
+
+[data-module="band-lineup"] .bl-name {
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ right: 6px;
+ max-width: calc(100% - 12px);
+ padding: 3px 7px;
+ border-radius: 6px;
+ background: color-mix(in srgb, var(--bg) 82%, transparent);
+ color: var(--text);
+ font-size: 0.74em;
+ font-weight: 700;
+ line-height: 1.12;
+ z-index: 5;
+ /* Show the whole name — wrap onto more lines rather than clipping with an ellipsis. */
+ white-space: normal;
+ overflow-wrap: break-word;
+ word-break: break-word;
+ hyphens: auto;
+}
+
+[data-module="band-lineup"] .bl-label {
+ padding: 5px 6px;
+ font-size: 0.72em;
+ font-weight: 600;
+ text-align: center;
+ color: var(--text);
+ background: color-mix(in srgb, var(--bg) 60%, transparent);
+ border-top: 1px solid var(--border);
+ min-height: 1.6em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ word-break: break-word;
+}
+
+[data-module="band-lineup"] .bl-empty {
+ border-style: dashed;
+ opacity: 0.55;
+}
+[data-module="band-lineup"] .bl-empty .bl-label {
+ background: transparent;
+ border-top: none;
+ margin: auto;
+ color: var(--muted);
+}
+
+/* ---- Birthday effects (confetti canvas + gold glow ring) ---- */
+[data-module="band-lineup"] .bl-confetti {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ pointer-events: none;
+}
+[data-module="band-lineup"] .bl-birthday-subtle {
+ animation: bl-glow-subtle 3.4s ease-in-out infinite;
+}
+[data-module="band-lineup"] .bl-birthday-full {
+ animation: bl-glow-full 2.2s ease-in-out infinite;
+}
+@keyframes bl-glow-subtle {
+ 0%, 100% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent), 0 0 14px color-mix(in srgb, var(--accent) 20%, transparent); }
+ 50% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 20px color-mix(in srgb, var(--accent) 34%, transparent); }
+}
+@keyframes bl-glow-full {
+ 0%, 100% { box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--accent) 80%, transparent), 0 0 20px color-mix(in srgb, var(--accent) 42%, transparent); }
+ 50% { box-shadow: inset 0 0 0 3px var(--accent), 0 0 34px color-mix(in srgb, var(--accent) 66%, transparent); }
+}
+
+/* ---- Editor (Edit button): board drop-targets + uploaded-photo library ---- */
+[data-module="band-lineup"] .bl-btn {
+ border: 1px solid var(--border);
+ background: var(--panel);
+ color: var(--text);
+ border-radius: 6px;
+ padding: 5px 11px;
+ font: inherit;
+ font-size: 0.82em;
+ cursor: pointer;
+}
+[data-module="band-lineup"] .bl-btn.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
+[data-module="band-lineup"] .bl-btn:hover { border-color: var(--accent); }
+
+[data-module="band-lineup"] .bl-editor {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg);
+}
+[data-module="band-lineup"] .bl-editor-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border);
+ flex: 0 0 auto;
+}
+[data-module="band-lineup"] .bl-editor-bar b { color: var(--text); }
+[data-module="band-lineup"] .bl-editor-spacer { flex: 1; }
+[data-module="band-lineup"] .bl-folder { display: flex; align-items: center; gap: 6px; min-width: 0; }
+[data-module="band-lineup"] .bl-folder-path { font-size: 0.72em; color: var(--muted); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+[data-module="band-lineup"] .bl-editor-body { flex: 1; min-height: 0; overflow: auto; padding: 10px; }
+
+[data-module="band-lineup"] .bl-edit-board {
+ display: grid;
+ grid-template-columns: repeat(var(--bl-cols, 7), minmax(0, 1fr));
+ gap: 6px;
+ margin-bottom: 12px;
+}
+[data-module="band-lineup"] .bl-edit-slot {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--panel);
+ display: flex;
+ flex-direction: column;
+}
+[data-module="band-lineup"] .bl-edit-slot.bl-drop-over { border-color: var(--accent); box-shadow: inset 0 0 0 2px var(--accent); }
+[data-module="band-lineup"] .bl-edit-photo { position: relative; aspect-ratio: 3 / 4; background: color-mix(in srgb, var(--bg) 70%, black); overflow: hidden; }
+[data-module="band-lineup"] .bl-edit-photo .bl-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
+[data-module="band-lineup"] .bl-edit-drop {
+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+ color: var(--muted); font-size: 1.6em; border: 1px dashed var(--border);
+}
+[data-module="band-lineup"] .bl-edit-namerow { display: flex; align-items: center; gap: 2px; padding: 2px; }
+[data-module="band-lineup"] .bl-edit-name {
+ flex: 1; min-width: 0; text-align: left; background: transparent; border: 0;
+ color: var(--text); font: inherit; font-size: 0.72em; font-weight: 600; padding: 3px 4px;
+ cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-radius: 4px;
+}
+[data-module="band-lineup"] .bl-edit-name:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
+[data-module="band-lineup"] .bl-edit-clear {
+ flex: 0 0 auto; width: 20px; height: 20px; border: 0; border-radius: 4px;
+ background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); cursor: pointer; font-size: 0.9em; line-height: 1;
+}
+[data-module="band-lineup"] .bl-edit-label { padding: 3px 5px 5px; font-size: 0.64em; color: var(--muted); text-align: center; border-top: 1px solid var(--border); word-break: break-word; }
+
+[data-module="band-lineup"] .bl-library {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
+ gap: 6px;
+ border-top: 1px solid var(--border);
+ padding-top: 10px;
+}
+[data-module="band-lineup"] .bl-lib-empty { grid-column: 1 / -1; color: var(--muted); font-size: 0.85em; padding: 10px 2px; }
+[data-module="band-lineup"] .bl-lib-item {
+ position: relative; border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
+ background: var(--panel); cursor: grab; display: flex; flex-direction: column;
+}
+[data-module="band-lineup"] .bl-lib-item:active { cursor: grabbing; }
+[data-module="band-lineup"] .bl-lib-selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
+[data-module="band-lineup"] .bl-lib-photo { position: relative; aspect-ratio: 3 / 4; background: color-mix(in srgb, var(--bg) 70%, black); overflow: hidden; }
+[data-module="band-lineup"] .bl-lib-img { width: 100%; height: 100%; object-fit: cover; object-position: center top; pointer-events: none; }
+[data-module="band-lineup"] .bl-lib-name {
+ border: 0; background: transparent; color: var(--text); font: inherit; font-size: 0.68em; font-weight: 600;
+ padding: 3px 4px; text-align: center; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+[data-module="band-lineup"] .bl-lib-del {
+ position: absolute; top: 3px; right: 3px; width: 18px; height: 18px; border: 0; border-radius: 4px;
+ background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
+}
+[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
diff --git a/tools/pco-band-mock.js b/tools/pco-band-mock.js
new file mode 100644
index 0000000..de5e372
--- /dev/null
+++ b/tools/pco-band-mock.js
@@ -0,0 +1,49 @@
+// Minimal Planning Center mock for the Band Lineup module: service types, one
+// plan today, its team_members (with person+team includes), People birthdates,
+// and avatar bytes. Enough to exercise the whole board build.
+const http = require('http');
+const PORT = Number(process.argv[2] || 24710);
+const JPEG = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AfwD/2Q==', 'base64');
+const TODAY = new Date();
+const iso = (d) => d.toISOString();
+// One person's birthday lands in this Wed–Tue week (today is a Wed in the test).
+const inWeek = new Date(TODAY.getFullYear(), TODAY.getMonth(), TODAY.getDate() + 2);
+const bdayInWeek = `1994-${String(inWeek.getMonth()+1).padStart(2,'0')}-${String(inWeek.getDate()).padStart(2,'0')}`;
+
+const PEOPLE = {
+ p1: { name: 'Lemuel Maldonado', pos: 'Vox 1', team: 'Vocals', bd: '1990-03-04' },
+ p2: { name: 'Wes Carter', pos: 'Vox 2', team: 'Vocals', bd: null },
+ p3: { name: 'Jessica Proulx', pos: 'Vox 3', team: 'Vocals', bd: '1992-11-01' },
+ p4: { name: 'Jeremy Cruz', pos: 'Guitar / Lead', team: 'Band', bd: null },
+ p5: { name: 'Alex Gaitan', pos: 'Drums', team: 'Band', bd: bdayInWeek }, // celebrating
+ p6: { name: 'Rosaliz Gomez', pos: 'Piano', team: 'Band', bd: null },
+ p7: { name: 'George Acosta', pos: 'MC', team: 'Production', bd: null },
+ p8: { name: 'Not Shown', pos: 'Camera 2', team: 'Video', bd: null }, // team not allowed → filtered out
+};
+const TEAMS = { Vocals: 't1', Band: 't2', Production: 't3', Video: 't4' };
+
+function send(res, obj) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x'); const p = u.pathname;
+ if (p.startsWith('/avatar/')) { res.writeHead(200, { 'Content-Type': 'image/jpeg' }); return res.end(JPEG); }
+ if (p === '/services/v2/folders') return send(res, { data: [] });
+ if (p === '/services/v2/service_types') return send(res, { data: [{ id: '1', type: 'ServiceType', attributes: { name: 'Sunday Service' }, relationships: { parent: { data: null } } }] });
+ if (/^\/services\/v2\/service_types\/1\/plans$/.test(p)) {
+ if (u.searchParams.get('filter') === 'past') return send(res, { data: [] });
+ return send(res, { data: [{ id: '100', type: 'Plan', attributes: { sort_date: iso(TODAY), title: 'Taking Thoughts Captive - Part 4', dates: 'September 20, 2026' } }] });
+ }
+ if (/^\/services\/v2\/service_types\/1\/plans\/100\/team_members$/.test(p)) {
+ const data = []; const included = [];
+ for (const [id, v] of Object.entries(PEOPLE)) {
+ data.push({ id: `tm-${id}`, type: 'PlanPerson', attributes: { status: 'C', team_position_name: v.pos }, relationships: { person: { data: { type: 'Person', id } }, team: { data: { type: 'Team', id: TEAMS[v.team] } } } });
+ included.push({ id, type: 'Person', attributes: { full_name: v.name, photo_url: `http://127.0.0.1:${PORT}/avatar/${id}` } });
+ }
+ for (const [name, id] of Object.entries(TEAMS)) included.push({ id, type: 'Team', attributes: { name } });
+ return send(res, { data, included, links: {} });
+ }
+ const person = p.match(/^\/people\/v2\/people\/([^/]+)$/);
+ if (person) { const v = PEOPLE[person[1]]; return send(res, { data: { id: person[1], type: 'Person', attributes: { birthdate: v ? v.bd : null } } }); }
+ res.writeHead(404); res.end('{}');
+});
+server.listen(PORT, '127.0.0.1', () => console.log(`[pco-band-mock] on http://127.0.0.1:${PORT}`));