Compare commits

...
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 c73d0a3aac PCO Plan 1.6.0: the message document is matched by the plan's own name
The current item followed the live ProPresenter document by title alone,
and a sermon's document is named after the sermon — Waters names them
"YYMMDD Series Title - Part #" — never after its plan item. So the last
song stayed current through the whole message: North Attleboro on 20 Sept
spent 65 minutes on one song in two of its three services.

A live document that reads like the plan itself — its date code, series
title and part — now lands on the plan's message item: an item titled
Message, Sermon or Teaching, else the first item under such a header. A
date code naming another day is a firm no, so last week's sermon stays
unplaced. Position in the ProPresenter playlist breaks a tie between
equal names, only while the playlist mirrors the plan item for item, and
never places a document on its own.

The log now says what it never said: every feed drop and reconnect, which
item the message document will land on, what placed each item and how,
and a live document that is not in the plan.

matching.test.js: 27 checks built from both campuses' real 27 Sept plans
and ProPresenter playlists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 13:06:53 -04:00
michael 441cfe3882 Merge pull request 'Grid density: cut the board finer or coarser, per browser' (#14) from claude/grid-size into main 2026-09-22 11:45:51 -04:00
Waters ChurchandClaude Fable 5.1 7ca65a845f Grid Size: the stepper's row is a name and a number
The Layouts menu row reads "Grid Size  − 12 +": the label is the setting's
name, the readout is the bare column count, and the default/finer/coarser
caption under the label is gone. The buttons keep their tooltips.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:35:26 -04:00
Waters ChurchandClaude Fable 5.1 e383070a9e Grid density: a coarser board settles its tiles instead of stacking them
Stepping the grid coarser pulled tiles past the right edge back in and
left them wherever that put them — on top of one another, which the
board never allows under a drag. Tiles now settle in reading order: one
that would land on a tile already settled takes the first free spot, the
same rule a newly added tile follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:26:05 -04:00
Waters ChurchandClaude Fable 5.1 2af7ef27ba Grid density: cut the board finer or coarser, per browser
Twelve columns fit a booth monitor and nothing else well: on a 4K wall a
three-wide tile is nearly a thousand pixels, and on a small screen four tiles
across is the most the grid allows. A stepper in the Layouts menu now cuts
the board into 8, 10, 12, 16, 20 or 24 columns. Row height follows the
column width, so cells keep their shape and a 3×2 tile is still a 3×2 tile,
only smaller — finer lets tiles be smaller so more fit, coarser suits a
screen read close up.

It is this browser's choice, not the server's: two displays at one location
can want different densities, so it saves with the layout — the local one
and a named one on the server — and a named layout comes back at the density
it was arranged on, which the load message says. An older layout with no
density is the default twelve.

Tiles keep their grid units when the density changes; the board simply gets
denser or coarser around them, and a tile now past the right edge is pulled
in. A module's minSize is enforced in cells as declared, so on a finer grid a
tile can be smaller than it could at twelve, which is the point; a new tile's
defaultSize is scaled to the density so it lands at about the size its author
meant. Leaving fullscreen goes back to the chosen density rather than the
stylesheet's twelve — that reset predates any notion of a chosen density.

Shell 1.9.0. The round-trip test carries gridCols and drops a value off the
scale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:19:49 -04:00
michael 5107b06f8c Merge pull request 'Band Lineup joins the repo — Christian's module, ready for every install' (#13) from claude/band-lineup-module into main 2026-09-22 09:44:18 -04:00
Waters ChurchandClaude Fable 5.1 5fb90190ae Band Lineup 1.11.0: into the catalog, and to the guide's rules
The module arrived in the previous commit exactly as it runs in North
Attleboro. This brings it to the interface rules every module follows and
puts it where the admin page can offer it.

The three hide-toggles — names, mic labels, parallax — sat in a folded
"Show / hide" group. Rule 8 puts four or fewer at the top of the gear with
no group at all, so they are ungrouped and first; the Birthday group keeps
its switch. The test-mode select loses its help line (rule 5). Everything
else in the gear was already within the rules, and every title-bar control
was already an icon with a tooltip.

The Planning Center mock moves out of the module folder to tools/, beside
the other upstream mocks, so an install does not download a development
tool with the module; README and CLAUDE.md say how to run it. The version
goes to 1.11.0 so the copy already installed on NA is offered the update
from the repo, and the module joins the README's list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:19:34 -04:00
christian 684f06abed Band Lineup module, as it runs in North Attleboro
The backstage stage board as a ProdDash tile: 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. The display half of the standalone Band
Lineup Electron app, ported to a module — imported here exactly as
installed on NA-Ingest (v1.10.1), so the repo is where it lives from now on.
2026-09-22 09:15:22 -04:00
michael 3435a0625e Merge pull request 'Layouts keep every tile they are given, and show what is on offer' (#12) from claude/layout-audit into main 2026-09-21 13:30:53 -04:00
20 changed files with 2539 additions and 53 deletions
+1
View File
@@ -44,6 +44,7 @@ title-bar controls). The rules that matter most:
for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and for the existing upstreams: `node tools/prodcom-mock.js` (ProdCom) and
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter). `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/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) ## Reference code (read-only forks — don't develop these here)
+30 -4
View File
@@ -273,7 +273,14 @@ below. Nothing is installed without someone clicking.
place and shows nothing, then appears when it is; it is never replaced by place and shows nothing, then appears when it is; it is never replaced by
the module's default tile. Saving over an existing name asks first, and the module's default tile. Saving over an existing name asks first, and
the server keeps the copy it replaced: **Restore previous** in Admin → the server keeps the copy it replaced: **Restore previous** in Admin →
Saved layouts swaps it back (and swapping again undoes that). Saved layouts swaps it back (and swapping again undoes that). The menu's
**Grid Size** stepper sets how many columns this browser cuts the board into —
8 to 24, 12 by default. Finer lets tiles be smaller so more fit across (a
4K wall), coarser suits a small screen read close up; row height follows,
so tiles keep their shape and simply change size. Going coarser, a tile
that no longer fits where it was moves to the first free spot rather than
onto another tile. It saves with the layout, so a named layout comes back
at the density it was arranged on.
- Every tile has a **status dot**: green = live, amber = connecting, - Every tile has a **status dot**: green = live, amber = connecting,
red = upstream unreachable (hover it for details). Tiles reconnect red = upstream unreachable (hover it for details). Tiles reconnect
automatically. automatically.
@@ -321,7 +328,14 @@ below. Nothing is installed without someone clicking.
description and item notes. When the ProPresenter module is running it description and item notes. When the ProPresenter module is running it
follows that module's live item, highlights the matching plan item, dims follows that module's live item, highlights the matching plan item, dims
what's done and shows the elapsed vs planned runtime and how far what's done and shows the elapsed vs planned runtime and how far
ahead/behind the service is. Admin config is the connection: Planning ahead/behind the service is. Items are matched by name — a document named
like the Planning Center item or its song, tags and all. The message is
the exception: a sermon's document is named after the sermon, so a live
document that reads like the plan itself — its date code, series title
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
message and lands on the plan's message item (an item titled Message,
Sermon or Teaching, else the first item under such a header). Admin
config is the connection: Planning
Center credentials (Personal Access Token app ID + secret, or an OAuth Center credentials (Personal Access Token app ID + secret, or an OAuth
token), the service type (a list grouped by your Planning Center folders) token), the service type (a list grouped by your Planning Center folders)
and an optional pinned plan. Everything about the display is per tile and an optional pinned plan. Everything about the display is per tile
@@ -351,6 +365,15 @@ below. Nothing is installed without someone clicking.
as `Label | Message` — add `| #channel` to bind one to a channel — and up as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis). to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices. 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 - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
@@ -362,8 +385,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 (Planning Center Services on `127.0.0.1:24700` — run ProdDash with
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and `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` — `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 run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
testing section of the [module guide](docs/MODULE-GUIDE.md). `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 ## Install as a desktop web app
+1 -1
View File
@@ -136,7 +136,7 @@ the app); its settings are kept for a later reinstall.
| `client` | yes | Client entry file, loaded as an ES module. | | `client` | yes | Client entry file, loaded as an ES module. |
| `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. |
| `style` | no | A stylesheet the shell injects once per module. | | `style` | no | A stylesheet the shell injects once per module. |
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. | | `minSize`, `defaultSize` | no | Tile size in grid cells, declared for the default grid (12 columns, 72 px rows). Defaults: min 1×1, default 4×3. A dashboard may cut its board finer or coarser (8–24 columns, from the Layouts menu; row height follows). `defaultSize` is scaled to that density when a tile is added, so a new tile lands at about the size you meant; `minSize` is enforced in cells as declared, so on a finer grid a tile can be made smaller than it could at 12. |
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. | | `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. | | `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. | | `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
+52
View File
@@ -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.
+299
View File
@@ -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 = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>';
const EDIT_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>';
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 = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
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 = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="18" r="2.5"/><circle cx="17" cy="16" r="2.5"/><path d="M8.5 18V5l11-2v13"/><path d="M8.5 8.5l11-2"/></svg>';
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 = '';
},
};
}
+198
View File
@@ -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,
};
+67
View File
@@ -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 ✓');
}
+154
View File
@@ -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"
}
}
}
+764
View File
@@ -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/<file> → image bytes (library + cached PCO photos)
* POST /library/<id> → rename / set birthday / replace photo (raw image body; ?name=&birthday=)
* POST /library/<id>/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/<index>/name → ?name= rename the occupant (or make a name-only spot)
* POST /slot/<index>/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[^>]*>([^<]+)</g;
let m;
while ((m = re.exec(html))) {
const id = m[1]; const name = m[2].trim();
if (id && !seen.has(id)) { seen.add(id); out.push({ id, name }); }
}
return out;
}
/** Download one public Drive file at full resolution (falls back to a large thumbnail). */
async function driveFetchImage(fileId) {
const base = 'https://drive.usercontent.google.com/download';
const opts = { headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS) };
let resp = await fetch(`${base}?id=${fileId}&export=download`, opts);
let ct = String(resp.headers.get('content-type') || '');
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
// Large-file virus-scan interstitial: grab the confirm token and retry.
if (ct.includes('text/html')) {
const html = await resp.text();
const conf = /name="confirm"\s+value="([^"]+)"/.exec(html) || /[?&]confirm=([0-9A-Za-z_-]+)/.exec(html);
const uuid = /name="uuid"\s+value="([^"]+)"/.exec(html);
if (conf) {
resp = await fetch(`${base}?id=${fileId}&export=download&confirm=${conf[1]}${uuid ? `&uuid=${uuid[1]}` : ''}`, opts);
ct = String(resp.headers.get('content-type') || '');
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
}
}
const th = await fetch(`https://drive.google.com/thumbnail?id=${fileId}&sz=w2000`, opts);
const tct = String(th.headers.get('content-type') || '');
if (th.ok && tct.startsWith('image/')) return { buf: Buffer.from(await th.arrayBuffer()), ext: extFromContentType(tct) };
throw new Error('not an image');
}
/* ── the module ────────────────────────────────────────────────────── */
module.exports = {
init({ config, log }) {
const streams = new Set();
const dataDir = path.join(resolveDataDir(), MODULE_ID);
const photoDir = path.join(dataDir, 'photos');
const libraryPath = path.join(dataDir, 'library.json');
const boardPath = path.join(dataDir, 'board.json');
try { fs.mkdirSync(photoDir, { recursive: true }); } catch (err) { log(`cannot create ${photoDir}: ${err?.message || err}`); }
const slots = nonEmptyLines(config.slots).length ? lines(config.slots) : [];
const teamKeywords = nonEmptyLines(config.teams).length ? nonEmptyLines(config.teams) : ['band', 'worship', 'vocal', 'production', 'communication'];
const columns = Math.max(1, Number(config.columns) || 7);
const serviceTypeId = String(config.serviceTypeId || '').trim();
const planIdCfg = String(config.planId || '').trim();
// Photos folder: the admin "Photos folder" field wins when set; otherwise it's whatever the
// tile's "Choose folder…" picker saved into module state. Admin can't host a native picker
// (the shell renders plain fields), so the picker lives in the tile as the no-typing option.
const settingsPath = path.join(dataDir, 'settings.json');
const settings = readJsonFile(settingsPath, {}) || {};
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
const CHOIR_LABEL = 'CHOIR';
// The board's slots plus an extra CHOIR spot when the toggle is on. Its index is slots.length,
// so its assignment persists in board.json across toggles.
const effectiveSlots = () => (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:<id>"). 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/<id>/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=<i>&to=<j>.
'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=<index>&personId=<id> (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/<index>/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) }); }
},
};
},
};
+232
View File
@@ -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; }
+177 -3
View File
@@ -2,10 +2,12 @@
/** /**
* Title matching for the PCO Plan module — the one answer to "is this * Title matching for the PCO Plan module — the one answer to "is this
* ProPresenter document that Planning Center thing?", used twice: * ProPresenter document that Planning Center thing?", used three ways:
* *
* - mapping the document ProPresenter has live to a plan item (the * - mapping the document ProPresenter has live to a plan item (the
* highlight and the item runtimes), and * highlight and the item runtimes) — placeDocument(), below,
* - recognising the message document, which is named after the sermon
* and never after its plan item — the message rule, below, and
* - recognising the documents an admin picked as the service start / end * - recognising the documents an admin picked as the service start / end
* cues (see service-timing.js). * cues (see service-timing.js).
* *
@@ -82,4 +84,176 @@ function isCue(liveName, cueDocument) {
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD; return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
} }
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue }; /* ── the message rule ──────────────────────────────────────────────────
A sermon's ProPresenter document is named after the sermon, never after
the plan item that stands for it ("Message", "Resi Message"), so no name
comparison can place it — and the previous song stayed "current" through
a whole message. What the document's name does carry is the plan's own
identity. Waters names them "YYMMDD Series Title - Part #": the plan's
date, its series title and its title. A live document that reads like
the plan is the plan's message, and lands on the plan's message item,
however that item happens to be titled. */
const MESSAGE_WORDS = /\b(message|sermon|teaching|homily)\b/i;
const MESSAGE_WORD_ONLY = /^\s*(message|sermon|teaching|homily)\s*$/i;
/** Undated, the name must read like "Series Title Part #" at least this closely… */
const MESSAGE_THRESHOLD = 0.75;
/** …dated with this plan's date, it need only resemble the series or the title. */
const DATED_MESSAGE_THRESHOLD = 0.6;
/** The leading "YYMMDD" / "YYYYMMDD" of a document name as a calendar day, or null. */
function leadingDateCode(raw) {
const m = /^\s*(\d{8}|\d{6})(?!\d)/.exec(String(raw || ''));
if (!m) return null;
const code = m[1];
const y = code.length === 8 ? Number(code.slice(0, 4)) : 2000 + Number(code.slice(0, 2));
const mo = Number(code.slice(-4, -2));
const d = Number(code.slice(-2));
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
return { y, m: mo, d };
}
/** The local calendar day of an epoch-ms instant, in the same shape. */
function dayOf(ms) {
const t = new Date(Number(ms));
if (!Number.isFinite(t.getTime())) return null;
return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() };
}
function sameDay(a, b) {
return Boolean(a && b) && a.y === b.y && a.m === b.m && a.d === b.d;
}
/**
* Does this live document read like the plan itself — "260927 Where is Your
* Faith? - Part 6" for the plan "Where is Your Faith?" / "Part 6" dated
* 27 Sept 2026? Returns { score, dated } or null.
*
* A date code naming another day is a firm no: last week's sermon is not
* this plan's message. A date code naming the plan's day is strong evidence,
* so the rest need only resemble the series or the title. Without a date
* the name must read like series + title together — and carry the title
* when the plan has both, so a series bumper ("Where is Your Faith?") is
* not taken for the sermon.
*/
function messageDocumentScore(name, plan) {
const series = String(plan?.seriesTitle || '').trim();
const title = String(plan?.title || '').trim();
if (!series && !title) return null;
const code = leadingDateCode(name);
const planDay = plan?.sortDate ? dayOf(plan.sortDate) : null;
if (code && planDay && !sameDay(code, planDay)) return null;
const dated = Boolean(code && planDay);
const full = [series, title].filter(Boolean).join(' ');
const fullScore = similarity(name, full);
if (dated) {
const score = Math.max(fullScore, series ? similarity(name, series) : 0, title ? similarity(name, title) : 0);
return score >= DATED_MESSAGE_THRESHOLD ? { score, dated: true } : null;
}
if (fullScore < MESSAGE_THRESHOLD) return null;
if (series && title && similarity(name, title) < DATED_MESSAGE_THRESHOLD) return null;
return { score: fullScore, dated: false };
}
/**
* The plan item that stands for the message: an item titled with the word
* ("Message", "Resi Message" — a bare "Message" first), else the first item
* under a header so titled (a "Sermon" section whose one item is the sermon's
* own title). Null when the plan has no such slot.
*/
function messageItemOf(items) {
let best = null;
let inSection = false;
for (const it of Array.isArray(items) ? items : []) {
if (!it) continue;
const title = String(it.title || '');
if (it.itemType === 'header') {
inSection = MESSAGE_WORDS.test(title);
continue;
}
const rank = MESSAGE_WORD_ONLY.test(title) ? 3 : MESSAGE_WORDS.test(title) ? 2 : inSection ? 1 : 0;
if (rank && (!best || rank > best.rank)) best = { item: it, rank };
if (rank === 3) break;
}
return best ? best.item : null;
}
/**
* Does ProPresenter's playlist mirror the plan item for item? ProPresenter
* builds a Planning Center playlist that way — every plan item, headers
* included, each flagged as PCO — until an operator adds something of their
* own (a song tag, say), which shifts every position after it. Same length,
* headers in the same places and every item PCO-flagged is the test.
*/
function playlistMirrorsPlan(ppItems, items) {
if (!Array.isArray(ppItems) || !Array.isArray(items) || !items.length || ppItems.length !== items.length) return false;
return ppItems.every((p, i) => Boolean(p?.isPco) && (p?.type === 'header') === (items[i]?.itemType === 'header'));
}
/** Best plan item by title, the ones ahead of the current item winning ties. */
function bestByName(name, items, all, ctx) {
const currentIdx = items.findIndex((it) => it.id === ctx?.currentItemId);
const hint = ctx?.hint;
const wanted = hint && hint.mirror && Number.isInteger(hint.index) && hint.index >= 0 ? all[hint.index] : null;
let best = null;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
if (score < MATCH_THRESHOLD) continue;
// Distance ahead of the current item (wrapping so items behind rank last).
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
const atPosition = Boolean(wanted && item.id === wanted.id);
const candidate = { item, score, dist, atPosition };
if (!best || score > best.score + 0.001) {
best = candidate;
continue;
}
if (Math.abs(score - best.score) > 0.001) continue;
if ((atPosition && !best.atPosition) || (atPosition === best.atPosition && dist < best.dist)) best = candidate;
}
return best ? { item: best.item, score: best.score, via: 'name' } : null;
}
/**
* Where a live ProPresenter document sits in the plan: { item, score, via }
* or null. `ctx` is { items, plan, currentItemId, hint }; `hint` (optional)
* is { index, mirror } — the document's position in the ProPresenter
* playlist and whether that playlist mirrors the plan (playlistMirrorsPlan).
*
* By name first: the item (or song) title most like the document's name,
* the ones ahead of the current item winning ties — a near miss lands on the
* right row. Then the message rule: a document that reads like the plan
* itself lands on the plan's message item. It overrides a name hit that is
* anything short of identical — a song called "Faith" must not catch
* "260927 Where is Your Faith? - Part 6" — outright when the document is
* dated with the plan's day, and when it reads better otherwise. Position
* is never a match on its own: it only breaks a tie between equally good
* names, and only while the playlist mirrors the plan.
*/
function placeDocument(name, ctx) {
const all = Array.isArray(ctx?.items) ? ctx.items : [];
const items = all.filter((it) => it && it.itemType !== 'header');
if (!items.length) return null;
const byName = bestByName(name, items, all, ctx);
const asMessage = messageDocumentScore(name, ctx?.plan);
const messageItem = asMessage ? messageItemOf(all) : null;
if (messageItem && (!byName || (byName.score < 0.999 && (asMessage.dated || asMessage.score > byName.score)))) {
return { item: messageItem, score: asMessage.score, via: asMessage.dated ? 'message document, dated' : 'message document' };
}
return byName;
}
module.exports = {
MATCH_THRESHOLD,
TRIGGER_THRESHOLD,
MESSAGE_THRESHOLD,
cleanTitle,
normalize,
similarity,
isCue,
leadingDateCode,
messageDocumentScore,
messageItemOf,
playlistMirrorsPlan,
placeDocument,
};
+280
View File
@@ -0,0 +1,280 @@
'use strict';
/**
* Unit tests for the PCO Plan module's document matching — placing what
* ProPresenter has live onto a plan item, and the message rule.
*
* node modules/pco-plan/matching.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. The two
* plans are the campuses' real 27 Sept 2026 plans and ProPresenter playlists
* as they stood on 22 Sept, so the expectations read like a Sunday would.
*/
const assert = require('assert');
const {
MESSAGE_THRESHOLD,
leadingDateCode,
messageDocumentScore,
messageItemOf,
playlistMirrorsPlan,
placeDocument,
} = require('./matching');
let passed = 0;
function test(name, fn) {
try {
fn();
passed += 1;
console.log(` ok ${name}`);
} catch (err) {
console.log(` FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
throw err;
}
}
const SUNDAY = new Date(2026, 8, 27, 9, 30).getTime(); // 27 Sept 2026, 9:30 local — the plan's sort date
const PLAN = { id: '91274523', title: 'Part 6', seriesTitle: 'Where is Your Faith?', sortDate: SUNDAY };
const header = (id, title) => ({ id, itemType: 'header', title, song: null });
const item = (id, title, itemType = 'item', song = null) => ({ id, itemType, title, song: song ? { title: song } : null });
const pp = (name, type = 'presentation', isPco = true) => ({ name, type, isPco, isHidden: false });
// North Attleboro — the message is an item called "Message", linked in
// ProPresenter to the bumper video; the sermon slides are their own document.
const NA = [
header('h0', 'Doors Open'),
item('1', 'Preservice'),
item('2', '5 Minute Countdown', 'media'),
item('3', 'MC Announcements', 'media'),
header('h4', 'Worship'),
item('5', 'Call To Worship', 'media'),
item('6', 'Alleluia', 'song', 'Alleluia'),
item('7', 'What a God', 'song', 'What a God'),
item('8', 'Baptisms', 'media'),
item('9', 'Promised Land', 'song', 'Promised Land'),
item('10', 'Be Praised', 'song', 'Be Praised'),
header('h11', 'Message'),
item('12', 'Message'),
item('13', 'Standing Salvation Moment', 'media'),
header('h14', 'Closing'),
item('15', 'Postservice', 'song', '-Postservice'),
];
const NA_PP = [
pp('Doors Open', 'header'), pp('Preservice'), pp('5 Minute Countdown'), pp('MC Announcements'),
pp('Worship', 'header'), pp('Call To Worship'), pp('Alleluia - [ Full ]'), pp('What a God - [ Full ]'),
pp('Baptisms'), pp('Promised Land - [ Full ]'), pp('Be Praised - [ Full ]'),
pp('Message', 'header'), pp('Message Bumper'), pp('Standing Salvation Moment'),
pp('Closing', 'header'), pp('Postservice'),
];
// Apollo Beach — the message is the media item "Resi Message".
const AB = [
item('0', 'Pre Service Meeting', 'media'),
header('h1', 'Doors Open'),
item('2', 'Pre Service', 'media'),
item('3', '5 Minute Countdown', 'media'),
item('4', 'MC Announcements', 'media'),
header('h5', 'Worship'),
item('6', 'Call To Worship', 'media'),
item('7', 'Alleluia', 'song', 'Alleluia'),
item('8', 'What a God', 'song', 'What a God'),
item('9', 'The Blood', 'song', 'The Blood'),
item('10', 'Be Praised (feat. Kymberli Joye)', 'song', 'Be Praised'),
header('h11', 'Message'),
item('12', 'Resi Message', 'media'),
item('13', 'Standing Salvation Moment', 'media'),
header('h14', 'Closing'),
item('15', 'Post Service', 'media'),
];
const SERMON = '260927 Where is Your Faith? - Part 6';
const place = (name, items, extra = {}) => placeDocument(name, { items, plan: PLAN, currentItemId: '', ...extra });
const titleOf = (hit) => (hit ? hit.item.title : null);
console.log('date codes');
test('YYMMDD and YYYYMMDD lead a document name; anything else is not a date', () => {
assert.deepStrictEqual(leadingDateCode(SERMON), { y: 2026, m: 9, d: 27 });
assert.deepStrictEqual(leadingDateCode('20260927 Where is Your Faith? - Part 6'), { y: 2026, m: 9, d: 27 });
assert.strictEqual(leadingDateCode('9-27-26 Where is Your Faith?'), null);
assert.strictEqual(leadingDateCode('1234567 seven digits'), null);
assert.strictEqual(leadingDateCode('261340 no such month'), null);
assert.strictEqual(leadingDateCode('Alleluia'), null);
assert.strictEqual(leadingDateCode(''), null);
});
console.log('the message rule');
test('a document named like the plan, dated with its day, is the message', () => {
const hit = messageDocumentScore(SERMON, PLAN);
assert.ok(hit, 'recognised');
assert.strictEqual(hit.dated, true);
assert.strictEqual(hit.score, 1);
});
test("last week's document is not this plan's message", () => {
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 5', PLAN), null);
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 6', PLAN), null, 'even with the right part: the date names another day');
});
test('undated, series and part together still read as the message', () => {
const plain = messageDocumentScore('Where is Your Faith? - Part 6', PLAN);
assert.ok(plain && plain.dated === false && plain.score === 1);
const tagged = messageDocumentScore('Where Is Your Faith - Part 6 - [ Notes ]', PLAN);
assert.ok(tagged && tagged.score >= MESSAGE_THRESHOLD, 'tags and punctuation do not matter');
});
test('a series bumper is not the sermon', () => {
assert.strictEqual(messageDocumentScore('Where is Your Faith?', PLAN), null, 'the series alone, undated');
assert.strictEqual(messageDocumentScore('Where is Your Faith? Bumper', PLAN), null);
});
test("dated with the plan's day, the part alone is enough", () => {
const hit = messageDocumentScore('260927 Part 6', PLAN);
assert.ok(hit && hit.dated);
});
test('a dated document about something else is not the message', () => {
assert.strictEqual(messageDocumentScore('260927 Announcements', PLAN), null);
assert.strictEqual(messageDocumentScore('260927 Baptism Video', PLAN), null);
});
test('songs are not the message', () => {
assert.strictEqual(messageDocumentScore('What a God - [ Full ]', PLAN), null);
assert.strictEqual(messageDocumentScore('Alleluia', PLAN), null);
assert.strictEqual(messageDocumentScore('Message Bumper', PLAN), null);
});
test('a plan with neither series nor title has no message document', () => {
assert.strictEqual(messageDocumentScore(SERMON, { sortDate: SUNDAY }), null);
assert.strictEqual(messageDocumentScore(SERMON, null), null);
});
test('a plan without a date still recognises its own name', () => {
const hit = messageDocumentScore(SERMON, { title: 'Part 6', seriesTitle: 'Where is Your Faith?' });
assert.ok(hit && hit.dated === false && hit.score === 1);
});
console.log('the message item');
test('an item called Message is the message item; so is Resi Message', () => {
assert.strictEqual(messageItemOf(NA).id, '12');
assert.strictEqual(messageItemOf(AB).id, '12');
});
test('a bare "Message" beats an item that only contains the word', () => {
const withBumper = [...NA.slice(0, 12), item('12a', 'Message Bumper', 'media'), ...NA.slice(12)];
assert.strictEqual(messageItemOf(withBumper).id, '12');
assert.strictEqual(messageItemOf([item('a', 'Message Bumper', 'media')]).id, 'a', 'alone, it is the slot');
});
test('under a Sermon header, the first item is the message whatever it is called', () => {
const plan = [header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia'), header('s', 'Sermon'), item('2', 'Living Hope'), item('3', 'Response')];
assert.strictEqual(messageItemOf(plan).id, '2');
});
test('a plan without a message slot has none', () => {
assert.strictEqual(messageItemOf([header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia')]), null);
assert.strictEqual(messageItemOf([header('m', 'Message')]), null, 'a header with nothing under it');
assert.strictEqual(messageItemOf([]), null);
assert.strictEqual(messageItemOf(null), null);
});
console.log('the playlist mirror');
test("ProPresenter's Planning Center playlist mirrors the plan, headers and all", () => {
assert.strictEqual(playlistMirrorsPlan(NA_PP, NA), true);
});
test('an item the operator added, a header out of place or a non-PCO item breaks the mirror', () => {
const extra = [...NA_PP.slice(0, 10), pp('The Blood TAG'), ...NA_PP.slice(10)];
assert.strictEqual(playlistMirrorsPlan(extra, NA), false, 'a song tag shifts everything after it');
const shifted = [...NA_PP];
[shifted[3], shifted[4]] = [shifted[4], shifted[3]];
assert.strictEqual(playlistMirrorsPlan(shifted, NA), false);
const local = NA_PP.map((x, i) => (i === 8 ? pp('Baptisms', 'presentation', false) : x));
assert.strictEqual(playlistMirrorsPlan(local, NA), false);
assert.strictEqual(playlistMirrorsPlan([], []), false);
assert.strictEqual(playlistMirrorsPlan(null, NA), false);
});
console.log('placing what is live');
test('songs and media land by name, tags and all', () => {
const god = place('What a God - [ Full ]', NA);
assert.strictEqual(titleOf(god), 'What a God');
assert.strictEqual(god.via, 'name');
assert.strictEqual(titleOf(place('Resi Message - [ MEDIA ]', AB)), 'Resi Message');
assert.strictEqual(titleOf(place('Greatly To Be Praised - [ Full ]', AB)), 'Be Praised (feat. Kymberli Joye)', 'by the song title');
assert.strictEqual(titleOf(place('Pre Service - [ Sunday ]', AB)), 'Pre Service');
});
test('the sermon document lands on the message item at both campuses', () => {
const na = place(SERMON, NA);
assert.strictEqual(titleOf(na), 'Message');
assert.strictEqual(na.via, 'message document, dated');
assert.strictEqual(titleOf(place(SERMON, AB)), 'Resi Message');
assert.strictEqual(place('Where is Your Faith? - Part 6', NA).via, 'message document', 'undated still lands, and says so');
});
test('the bumper lands on the message item too, by name', () => {
const hit = place('Message Bumper', NA);
assert.strictEqual(titleOf(hit), 'Message');
assert.strictEqual(hit.via, 'name');
});
test('a song called Faith does not catch the sermon', () => {
const trap = [...NA.slice(0, 11), item('f', 'Faith', 'song', 'Faith'), ...NA.slice(11)];
assert.strictEqual(titleOf(place('Faith - [ Full ]', trap)), 'Faith', 'the song itself still lands');
assert.strictEqual(titleOf(place(SERMON, trap)), 'Message', 'dated: the message wins outright');
assert.strictEqual(titleOf(place('Where is Your Faith? - Part 6', trap)), 'Message', 'undated: it reads better than the song');
});
test('an item titled exactly like the document wins over the message slot', () => {
const titled = [...NA.slice(0, 12), item('s', 'Where is Your Faith? - Part 6'), ...NA.slice(12)];
assert.strictEqual(place(SERMON, titled).item.id, 's');
});
test('a plan without a message slot leaves the sermon document unplaced', () => {
const noSlot = NA.filter((it) => it.id !== '12' && it.id !== 'h11');
assert.strictEqual(place(SERMON, noSlot), null);
});
test("an unplanned document, or last week's sermon, lands nowhere", () => {
assert.strictEqual(place('Offering Slide', NA), null);
assert.strictEqual(place('260920 Where is Your Faith? - Part 5', NA), null);
});
test('nothing to place on an empty plan', () => {
assert.strictEqual(place(SERMON, []), null);
assert.strictEqual(place(SERMON, [header('h', 'Message')]), null);
});
// The same song twice: a reprise after "What a God".
const REPRISE = [...NA.slice(0, 8), item('6b', 'Alleluia', 'song', 'Alleluia'), ...NA.slice(8)];
const REPRISE_PP = [...NA_PP.slice(0, 8), pp('Alleluia - [ Reprise ]'), ...NA_PP.slice(8)];
test('a tie between equal names goes to the one ahead of the current item', () => {
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE).item.id, '6', 'nothing current yet: the first');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7' }).item.id, '6b', 'after What a God: the reprise');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '6b' }).item.id, '6b', 'the current one keeps a tie');
});
test('position breaks a tie only while the playlist mirrors the plan', () => {
const mirror = playlistMirrorsPlan(REPRISE_PP, REPRISE);
assert.strictEqual(mirror, true);
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror } }).item.id, '6b');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { hint: { index: 6, mirror } }).item.id, '6');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7', hint: { index: 6, mirror } }).item.id, '6', 'position wins the tie over "ahead"');
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror: false } }).item.id, '6', 'no mirror: back to "ahead"');
});
test('position never places a document on its own', () => {
const mirror = playlistMirrorsPlan(NA_PP, NA);
assert.strictEqual(place('Offering Slide', NA, { hint: { index: 8, mirror } }), null);
assert.strictEqual(titleOf(place('What a God - [ Full ]', NA, { hint: { index: 12, mirror } })), 'What a God', 'a wrong position cannot move a clear name');
});
console.log(`\n${passed} tests passed`);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "pco-plan", "id": "pco-plan",
"name": "PCO Plan", "name": "PCO Plan",
"version": "1.5.0", "version": "1.6.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services", "description": "Order of service from Planning Center Services",
"provides": [ "provides": [
+65 -30
View File
@@ -33,8 +33,12 @@
* Following ProPresenter: instead of talking to ProPresenter a second time, * Following ProPresenter: instead of talking to ProPresenter a second time,
* this module reads the propresenter-now-next module's own SSE feed over * this module reads the propresenter-now-next module's own SSE feed over
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and * loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
* matches the live presentation name to a plan item. Item start/end * places the live document on a plan item (matching.js placeDocument): by
* timestamps are tracked here, once, so every tile agrees on the runtime. * name, or — for the sermon, whose document is named after the sermon and
* never after its plan item — by reading like the plan itself ("YYMMDD
* Series Title - Part #"), which lands it on the plan's message item. Item
* start/end timestamps are tracked here, once, so every tile agrees on the
* runtime.
* *
* Service timing: a plan's services (9:00, 11:00…) take turns owning the * Service timing: a plan's services (9:00, 11:00…) take turns owning the
* service clock — the rules live in service-timing.js. A service starts at * service clock — the rules live in service-timing.js. A service starts at
@@ -52,7 +56,7 @@ const http = require('http');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { MATCH_THRESHOLD, similarity } = require('./matching'); const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
const { const {
MINUTE, MINUTE,
serviceTimes, serviceTimes,
@@ -434,6 +438,8 @@ module.exports = {
let inFlight = null; let inFlight = null;
let lastFrame = ''; let lastFrame = '';
let lastTimersSig = ''; let lastTimersSig = '';
let lastMessageItemId = null;
let lastMatchError = '';
/* — broadcasting — */ /* — broadcasting — */
@@ -582,6 +588,7 @@ module.exports = {
} }
state.plan = plan; state.plan = plan;
state.items = items; state.items = items;
noteMessageItem(items);
state.reachable = true; state.reachable = true;
state.lastError = ''; state.lastError = '';
state.lastFetched = Date.now(); state.lastFetched = Date.now();
@@ -596,6 +603,17 @@ module.exports = {
syncServiceSafe(Date.now(), null); syncServiceSafe(Date.now(), null);
} }
/** Say, once per plan, which item a document named like the plan will land on. */
function noteMessageItem(items) {
const msg = messageItemOf(items);
const id = msg ? msg.id : '';
if (id === lastMessageItemId) return;
lastMessageItemId = id;
log(msg
? `message item: "${msg.title}" — a live document named like this plan (its date, series and part) lands here`
: 'no message item in this plan — a document named like the plan has nowhere to land');
}
/* — item note categories (a tile's "Item notes" → Categories switches) — */ /* — item note categories (a tile's "Item notes" → Categories switches) — */
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
@@ -996,36 +1014,29 @@ module.exports = {
}; };
lastLiveName = keep.activeName || ''; lastLiveName = keep.activeName || '';
if (follow && keep.activeName) { if (follow && keep.activeName) {
const hit = matchItem(keep.activeName); const hit = matchItem(keep.activeName, hintFrom(lastView));
state.live.matched = Boolean(hit); state.live.matched = Boolean(hit);
if (hit) { if (hit) {
state.live.currentScore = hit.score; state.live.currentScore = hit.score;
startItem(hit.item.id); startItem(hit.item.id, keep.activeName, hit);
} }
} }
} }
/** Best plan item for a live presentation name, preferring the ones ahead of us. */ /**
function matchItem(name) { * The plan item a live document stands for — { item, score, via } or
const items = state.items.filter((it) => it.itemType !== 'header'); * null. The rules live in matching.js (placeDocument): by name, then the
if (!items.length) return null; * message rule for a document named like the plan itself; the playlist
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId); * position only breaks ties, and only while the playlist mirrors the plan.
let best = null; */
for (let i = 0; i < items.length; i += 1) { function matchItem(name, hint) {
const item = items[i]; return placeDocument(name, { items: state.items, plan: state.plan, currentItemId: state.live.currentItemId, hint });
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
if (score < MATCH_THRESHOLD) continue;
// Distance ahead of the current item (wrapping so items behind rank last).
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
if (
!best
|| score > best.score + 0.001
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
) {
best = { item, score, dist };
} }
}
return best; /** The live item's place in ProPresenter's playlist, and whether that playlist mirrors the plan. */
function hintFrom(view) {
const index = Number.isInteger(view?.activeIndex) ? view.activeIndex : -1;
return { index, mirror: index >= 0 && playlistMirrorsPlan(view?.items, state.items) };
} }
/** Remember a document ProPresenter put live, most recent first, for the admin selects. */ /** Remember a document ProPresenter put live, most recent first, for the admin selects. */
@@ -1077,6 +1088,7 @@ module.exports = {
function applyLiveView(view) { function applyLiveView(view) {
lastView = view && typeof view === 'object' ? view : null; lastView = view && typeof view === 'object' ? view : null;
const live = state.live; const live = state.live;
if (!live.available) log('following the ProPresenter module feed');
live.available = true; live.available = true;
live.reason = ''; live.reason = '';
live.ppEnabled = Boolean(view?.enabled); live.ppEnabled = Boolean(view?.enabled);
@@ -1095,23 +1107,40 @@ module.exports = {
broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise
return; return;
} }
lastLiveName = name;
rememberDocument(name); rememberDocument(name);
if (follow) { if (follow) {
const hit = matchItem(name); try {
const hit = matchItem(name, hintFrom(view));
live.matched = Boolean(hit); live.matched = Boolean(hit);
if (hit) { if (hit) {
live.currentScore = hit.score; live.currentScore = hit.score;
if (hit.item.id !== live.currentItemId) startItem(hit.item.id); if (hit.item.id !== live.currentItemId) startItem(hit.item.id, name, hit);
} else {
// Said in the log, because on the wall it looks like the plan
// stopped following: the highlight stays where it was.
const staying = state.items.find((it) => it.id === live.currentItemId);
log(`live document "${name}" is not in the plan — ${staying ? `"${staying.title}" stays current` : 'no current item'}`);
}
lastMatchError = '';
} catch (err) {
// Never silent: a matcher that throws would otherwise leave the
// highlight stuck with nothing in the log to say why.
const msg = err?.message || String(err);
if (msg !== lastMatchError) {
lastMatchError = msg;
log(`matching "${name}" failed: ${msg}`);
} }
} }
}
lastLiveName = name;
// The same document is also a possible service start / end cue. // The same document is also a possible service start / end cue.
syncServiceSafe(Date.now(), name); syncServiceSafe(Date.now(), name);
broadcast(); broadcast();
broadcastTimers(); broadcastTimers();
} }
function startItem(itemId) { /** Make an item current; `liveName` and `hit` say what placed it, for the log. */
function startItem(itemId, liveName, hit) {
const live = state.live; const live = state.live;
const now = Date.now(); const now = Date.now();
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) { if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
@@ -1120,12 +1149,18 @@ module.exports = {
live.currentItemId = itemId; live.currentItemId = itemId;
live.history[itemId] = { startedAt: now, endedAt: 0 }; live.history[itemId] = { startedAt: now, endedAt: 0 };
const item = state.items.find((it) => it.id === itemId); const item = state.items.find((it) => it.id === itemId);
log(`live item → ${item?.title || itemId}`); const how = hit
? ` (${hit.via}${hit.via === 'name' ? ` ${Number(hit.score).toFixed(2)}` : ''}${liveName ? `: "${liveName}"` : ''})`
: '';
log(`live item → ${item?.title || itemId}${how}`);
} }
function nowNextUnavailable(reason, retryMs) { function nowNextUnavailable(reason, retryMs) {
const live = state.live; const live = state.live;
if (live.available || live.reason !== reason) { if (live.available || live.reason !== reason) {
// Every drop is in the log: a highlight that stopped moving is
// otherwise indistinguishable from a feed that went away.
log(`ProPresenter module feed unavailable — ${reason}`);
live.available = false; live.available = false;
live.reason = reason; live.reason = reason;
live.ppReachable = false; live.ppReachable = false;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.8.0", "version": "1.9.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+126 -7
View File
@@ -5,7 +5,18 @@
settings) autosaves to this browser's localStorage — every browser is fully settings) autosaves to this browser's localStorage — every browser is fully
independent. Modules render inside tile bodies and never touch the shell. */ independent. Modules render inside tile bodies and never touch the shell. */
const COLS = 12; /* ── grid density ─────────────────────────────────────────────────────
How many columns the board is cut into. Twelve is the default; a finer
grid lets tiles be smaller and more of them fit across (a 4K wall), a
coarser one suits a small screen read close up. It is this browser's
choice — two displays at one location may want different densities — so
it saves with the layout, local and named, and a named layout comes back
at the density it was arranged on. Row height follows the column width, so
cells keep their shape: a 3×2 tile is still a 3×2 tile, only smaller. */
const GRID_STEPS = [8, 10, 12, 16, 20, 24];
const GRID_DEFAULT = 12;
const CELL_H_DEFAULT = 72; // px per row at the default density
let COLS = GRID_DEFAULT;
const LS_LAYOUT = 'proddash:layout'; const LS_LAYOUT = 'proddash:layout';
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
@@ -224,7 +235,7 @@ const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
function saveLayout() { function saveLayout() {
try { try {
const next = JSON.stringify({ v: 1, autoHideHeads, tiles }); const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, tiles });
const current = localStorage.getItem(LS_LAYOUT); const current = localStorage.getItem(LS_LAYOUT);
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current); if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
localStorage.setItem(LS_LAYOUT, next); localStorage.setItem(LS_LAYOUT, next);
@@ -275,6 +286,87 @@ function newId() {
/* ── grid geometry & collision ──────────────────────────────────────── */ /* ── grid geometry & collision ──────────────────────────────────────── */
function normalizeGridCols(value) {
const n = Number(value);
return GRID_STEPS.includes(n) ? n : GRID_DEFAULT;
}
function cellHeightFor(cols) {
return Math.round((CELL_H_DEFAULT * GRID_DEFAULT) / cols);
}
/** Cut the board into `cols` columns. Tiles keep their grid units — the
board gets denser or coarser and everything on it changes size with it.
A tile now past the right edge is pulled back in, and one that would then
sit on another moves to the first free spot instead. */
function setGridCols(value, { save = true } = {}) {
const next = normalizeGridCols(value);
const changed = next !== COLS;
COLS = next;
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
document.documentElement.style.setProperty('--cell-h', cellHeightFor(COLS) + 'px');
if (changed) {
// A coarser board may not have room for every tile where it was. Settle
// them in reading order: pull a tile past the right edge back in, and
// give one that would then land on a tile already settled the first free
// spot instead — tiles never overlap here, any more than under a drag.
const settled = [];
for (const tile of tiles.slice().sort((a, b) => a.y - b.y || a.x - b.x)) {
tile.w = Math.max(1, Math.min(COLS, tile.w));
tile.x = Math.max(0, Math.min(COLS - tile.w, tile.x));
if (settled.some((s) => overlaps(tile, s))) Object.assign(tile, spotAmong(tile.w, tile.h, settled));
settled.push(tile);
applyRect(tile);
notifyResize(tile);
}
rebuildDividers();
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
}
if (save) saveLayout();
}
const STEP_MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
const STEP_PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
/** The Layouts menu's density row — Grid Size − 12 + — which steps without
closing the menu, so the board can be watched changing behind it. */
function gridStepper() {
const row = document.createElement('div');
row.className = 'menu-stepper';
const label = document.createElement('span');
label.className = 'menu-stepper-label';
label.textContent = 'Grid Size';
const minus = document.createElement('button');
minus.type = 'button';
minus.className = 'menu-stepper-btn';
minus.setAttribute('data-tip', 'Coarser');
minus.innerHTML = STEP_MINUS_SVG;
const value = document.createElement('span');
value.className = 'menu-stepper-value';
const plus = document.createElement('button');
plus.type = 'button';
plus.className = 'menu-stepper-btn';
plus.setAttribute('data-tip', 'Finer');
plus.innerHTML = STEP_PLUS_SVG;
const paint = () => {
const i = GRID_STEPS.indexOf(COLS);
value.textContent = String(COLS);
minus.disabled = i <= 0;
plus.disabled = i >= GRID_STEPS.length - 1;
};
const step = (dir) => {
const i = GRID_STEPS.indexOf(COLS) + dir;
if (i < 0 || i >= GRID_STEPS.length) return;
setGridCols(GRID_STEPS[i]);
paint();
};
minus.addEventListener('click', () => step(-1));
plus.addEventListener('click', () => step(1));
paint();
row.append(label, minus, value, plus);
return row;
}
function cellMetrics() { function cellMetrics() {
const style = getComputedStyle(grid); const style = getComputedStyle(grid);
const gapRaw = parseFloat(style.columnGap); const gapRaw = parseFloat(style.columnGap);
@@ -297,6 +389,17 @@ function isFree(rect, excludeId) {
return isFreeExcept(rect, [excludeId]); return isFreeExcept(rect, [excludeId]);
} }
/** First spot, scanning top-to-bottom, left-to-right, clear of these tiles. */
function spotAmong(w, h, others) {
for (let y = 0; y < 400; y += 1) {
for (let x = 0; x <= COLS - w; x += 1) {
const rect = { x, y, w, h };
if (!others.some((o) => overlaps(rect, o))) return { x, y };
}
}
return { x: 0, y: 0 };
}
/** First free spot scanning top-to-bottom, left-to-right. */ /** First free spot scanning top-to-bottom, left-to-right. */
function findSpot(w, h) { function findSpot(w, h) {
for (let y = 0; y < 200; y += 1) { for (let y = 0; y < 200; y += 1) {
@@ -990,10 +1093,14 @@ function addTile(moduleId, entry = null) {
toast(`${entry.name} is already on this dashboard`); toast(`${entry.name} is already on this dashboard`);
return; return;
} }
// Sizes are declared for the default twelve-column grid; scaled so a new
// tile lands at about the size its author meant, whatever the density here.
const scale = COLS / GRID_DEFAULT;
const defaultSize = entry?.defaultSize || man?.defaultSize; const defaultSize = entry?.defaultSize || man?.defaultSize;
const floor = minSizeOf({ module: moduleId, minSize: entry?.minSize });
const size = { const size = {
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)), w: Math.max(floor.w, 1, Math.min(COLS, Math.round((defaultSize?.w || 4) * scale))),
h: Math.max(1, defaultSize?.h || 3), h: Math.max(floor.h, 1, Math.round((defaultSize?.h || 3) * scale)),
}; };
const spot = findSpot(size.w, size.h); const spot = findSpot(size.w, size.h);
const settings = {}; const settings = {};
@@ -1991,6 +2098,8 @@ function renderLayout(layout) {
// "reset" can simply hand over no tiles. // "reset" can simply hand over no tiles.
const list = Array.isArray(layout) ? layout : (layout?.tiles || []); const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
clearAllTiles(); clearAllTiles();
// Density first: the tiles are clamped and placed against it.
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
tiles = list.map(sanitizeTile); tiles = list.map(sanitizeTile);
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false }); if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile); for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
@@ -2057,6 +2166,13 @@ function renderAddMenu(menu) {
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => { wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
menu.innerHTML = ''; menu.innerHTML = '';
// Density first: the one thing in here that is not a layout but the board
// the layouts are arranged on.
menu.appendChild(gridStepper());
const stepperDivider = document.createElement('div');
stepperDivider.className = 'menu-divider';
menu.appendChild(stepperDivider);
menu.appendChild(menuItem('Save as named layout…', async () => { menu.appendChild(menuItem('Save as named layout…', async () => {
menu.hidden = true; menu.hidden = true;
if (!tiles.length) { if (!tiles.length) {
@@ -2088,7 +2204,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const res = await fetch('/api/layouts/' + encodeURIComponent(name), { const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: { autoHideHeads, tiles } }), body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, tiles } }),
}); });
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')'); if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
@@ -2136,15 +2252,17 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
// does not live-link browsers together. // does not live-link browsers together.
let outgoing = null; let outgoing = null;
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ } try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
const gridBefore = COLS;
renderLayout(body.layout); renderLayout(body.layout);
saveLayout(); saveLayout();
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ } try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
const unshown = unshownTileNames(); const unshown = unshownTileNames();
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`; const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
const note = unshown.length const note = unshown.length
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now` ? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
: ''; : '';
toast(`Loaded “${l.name}”: ${count}${note}`, false, { toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
ms: unshown.length ? 10000 : 7000, ms: unshown.length ? 10000 : 7000,
action: outgoing ? { action: outgoing ? {
label: 'Undo', label: 'Undo',
@@ -2300,7 +2418,8 @@ function applyFullscreenFit() {
} }
function clearFullscreenFit() { function clearFullscreenFit() {
grid.style.gridTemplateColumns = ''; // Back to this browser's density, not the stylesheet's twelve.
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
grid.style.gridAutoRows = ''; grid.style.gridAutoRows = '';
gridwrap.classList.remove('no-scroll'); gridwrap.classList.remove('no-scroll');
for (const tile of tiles) { for (const tile of tiles) {
+30
View File
@@ -187,6 +187,36 @@ body.menu-hidden #topbar {
white-space: normal; white-space: normal;
} }
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; } .menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
/* the Layouts menu's grid-density row: Grid Size − 12 + */
.menu-stepper {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px 8px;
font-size: 13px;
}
.menu-stepper-label { flex: 1; }
.menu-stepper-btn {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
}
.menu-stepper-btn:hover { border-color: var(--muted); }
.menu-stepper-btn[disabled] { opacity: 0.35; cursor: default; }
.menu-stepper-btn svg { width: 12px; height: 12px; display: block; }
.menu-stepper-value {
min-width: 28px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.menu-title { .menu-title {
padding: 6px 10px 3px; padding: 6px 10px 3px;
color: var(--muted); color: var(--muted);
+4
View File
@@ -1619,6 +1619,7 @@ function previousLayoutPath(name) {
so an old file does not haunt a new client. */ so an old file does not haunt a new client. */
const LAYOUT_VERSION = 2; const LAYOUT_VERSION = 2;
const GRID_STEPS = [8, 10, 12, 16, 20, 24]; // the shell's grid densities
const MAX_LAYOUT_TILES = 100; const MAX_LAYOUT_TILES = 100;
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']); const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
const RETIRED_TILE_FIELDS = new Set(['headHidden']); const RETIRED_TILE_FIELDS = new Set(['headHidden']);
@@ -1644,6 +1645,9 @@ function sanitizeLayout(raw) {
// Whether tile title bars auto-hide is part of how a dashboard is // Whether tile title bars auto-hide is part of how a dashboard is
// arranged, so it travels with the layout like the tiles themselves. // arranged, so it travels with the layout like the tiles themselves.
autoHideHeads: Boolean(raw?.autoHideHeads), autoHideHeads: Boolean(raw?.autoHideHeads),
// The grid density the layout was arranged on, when it says and it is one
// the shell knows; a browser loading it cuts its board the same way.
...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}),
tiles: tiles.map((t) => { tiles: tiles.map((t) => {
const tile = { const tile = {
id: String(t?.id || ''), id: String(t?.id || ''),
+4 -2
View File
@@ -88,7 +88,7 @@ async function waitFor(url, ms = 15000) {
headHidden: true, headHidden: true,
}; };
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, tiles: [tile] } }) }); let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, gridCols: 16, tiles: [tile] } }) });
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body)); assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
assert.equal(r.body.replaced, false, 'a first save replaces nothing'); assert.equal(r.body.replaced, false, 'a first save replaces nothing');
@@ -97,6 +97,7 @@ async function waitFor(url, ms = 15000) {
const got = r.body.layout; const got = r.body.layout;
assert.equal(got.v, 2, 'the file carries a schema version'); assert.equal(got.v, 2, 'the file carries a schema version');
assert.equal(got.autoHideHeads, true); assert.equal(got.autoHideHeads, true);
assert.equal(got.gridCols, 16, 'grid density travels with the layout');
const t = got.tiles[0]; const t = got.tiles[0];
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k); for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
assert.deepEqual(t.settings, tile.settings, 'settings'); assert.deepEqual(t.settings, tile.settings, 'settings');
@@ -107,7 +108,7 @@ async function waitFor(url, ms = 15000) {
assert.equal('headHidden' in t, false, 'a retired field is dropped'); assert.equal('headHidden' in t, false, 'a retired field is dropped');
// Saving over the name keeps the copy it replaced. // Saving over the name keeps the copy it replaced.
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [{ ...tile, title: 'Service v2' }] } }) }); r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { gridCols: 13, tiles: [{ ...tile, title: 'Service v2' }] } }) });
assert.equal(r.status, 200); assert.equal(r.status, 200);
assert.equal(r.body.replaced, true, 'the second save reports a replacement'); assert.equal(r.body.replaced, true, 'the second save reports a replacement');
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept'); assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
@@ -125,6 +126,7 @@ async function waitFor(url, ms = 15000) {
assert.equal(r.status, 200); assert.equal(r.status, 200);
r = await api('/api/layouts/Booth%20A'); r = await api('/api/layouts/Booth%20A');
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again'); assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
assert.equal('gridCols' in r.body.layout, false, 'a density the shell does not offer is dropped, not stored');
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' }); r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 404, 'nothing to restore is said, not faked'); assert.equal(r.status, 404, 'nothing to restore is said, not faked');
+49
View File
@@ -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}`));