Compare commits

..
Author SHA1 Message Date
christianandClaude Opus 5.5 79d3237328 Resi Cues 1.0.3: a login without access to the venue stops signing in every poll
Every 401/403 was answered by signing in again on the spot. For a login that
works but may not list the account's venues (media@waters.church at North
Attleboro got 403 on /venues), that was a successful sign-in on every 1s poll
-- the pattern that had just got another Resi account locked.

- A refusal straight after signing in is a permission, not an expired
  session: it is held off like a failed login, with the message "This Resi
  login isn't allowed to see its venues (403) — ask a Resi admin for access".
  Only a session over a minute old is renewed.
- 429 on any request holds off too, not only on the login.
- The players cache-bust fallback no longer drops the parameter for good on a
  401/403/429, which are about the login rather than the parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:27:38 -04:00
christianandClaude Opus 5.5 67f612e371 Resi Cues 1.0.2: a failed Resi login holds off instead of retrying every second
A failed login left no session, so the next 1s poll signed in again, and the
next, for ever. Resi answers that with 429s, and since the retries never let
up the account never got out of them: at North Attleboro the decoder picker
stayed empty with "Resi login failed (429)". The picker made it worse by
building a fresh client -- and signing in -- on every admin page load.

- After a failed login, further attempts wait 15s, doubling to 5min (or as
  long as Resi's Retry-After asks); calls in between fail at once with the
  reason and how long until the next try.
- The status says what happened: rate-limited, password rejected, or Resi
  unreachable.
- The decoder picker reuses the running module's client when the login
  matches, otherwise one client per login, so it shares the hold-off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:44:14 -04:00
christianandClaude Opus 5.5 0f0adb4c60 Resi Cues 1.0.1: the next cue's name keeps moving on a 30-second Resi
On a Resi that publishes every ~30s, the board could start a few seconds
before a publish and learn a 2s cadence from that partial first gap. Every
honest 30s gap after that was thrown out as a pause, so playback read as
paused 15s into every update: the countdown froze, and the next cue -- and
the name on the Timers card -- stopped moving on.

- The partial gap right after startup is no longer learned, and one outlier
  gap can no longer lock out the real cadence.
- Until a cadence is measured, a pause is only called after 45s, not 15s.
- The Timers card is titled with the cue's name; its status line now says
  "Next cue" / "Held" instead of repeating the name.
- server.test.js awaited nothing, so an async test raced the next one and
  failed on main; tests now run one at a time.

Same fix as the Resi -> ProPresenter bridge and the Companion module 0.1.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:59:12 -04:00
12 changed files with 241 additions and 1840 deletions
+1 -24
View File
@@ -323,10 +323,7 @@ below. Nothing is installed without someone clicking.
session (Start ProdDash.command) and approve the one-time prompt; a server session (Start ProdDash.command) and approve the one-time prompt; a server
started over SSH or by bare launchd silently captures zeros. (Engine started over SSH or by bare launchd silently captures zeros. (Engine
self-test: `node modules/propresenter-timers/ltc.selftest.js`.) self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
- **PCO Plan** — Planning Center Services, as two tiles in the + picker: - **PCO Plan** — the order of service from Planning Center Services: every
**Plan** (the order of service) and **Teams** (who is scheduled).
**Plan** shows every
plan item with its scheduled start time, length, type, song key, plan item with its scheduled start time, length, type, song key,
description and item notes. 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
@@ -345,26 +342,6 @@ below. Nothing is installed without someone clicking.
(gear menu): the top-bar text (placeholders such as (gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a `{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail. show/hide checklist for every detail.
With an **AbleSet** module installed and configured, the Plan tile also
follows the band: while AbleSet plays a song that is the item on screen,
that item's background fills as the song goes, and its sections slide past
in a strip at the bottom right of the item, the one playing filling in
turn. Both are switches in the tile's gear (**Song progress**, **Song
sections**), and both are simply absent when there is no AbleSet module, it
is switched off, or it has no host — nothing to turn off and nothing to
explain.
**Teams** shows the same plan's team and position assignments — each team
as a band of its people, with their photo (fetched through ProdDash, never
by the browser; initials when Planning Center has none) and a dot for
confirmed, unconfirmed or declined. Its gear menu picks which teams and
which positions appear, groups people under their position or lists them
plainly, and sets the colours: one for the team headers, or **Tint teams**
to give each team its own, optionally carried into the rows. Teams nobody
has signed up for are hidden until you ask for them — that list comes from
the service type, since Planning Center only returns a team once somebody
is on it.
- **Slack** — live transcripts of this location's Slack channels, plus - **Slack** — live transcripts of this location's Slack channels, plus
PIN-protected sending as the location. The + picker lists one transcript PIN-protected sending as the location. The + picker lists one transcript
tile per configured channel, titled `#channel`, and two send surfaces (one tile per configured channel, titled `#channel`, and two send surfaces (one
+1 -154
View File
@@ -1,9 +1,5 @@
/* PCO Plan — client part. /* PCO Plan — client part.
The module presents two tiles and this is the entry for both: the Teams
tile (picker entry "teams") is built by teams-client.js, everything below
draws the Plan tile.
The server part reads Planning Center and (optionally) follows the The server part reads Planning Center and (optionally) follows the
ProPresenter module's live item; this tile only renders the plan it is ProPresenter module's live item; this tile only renders the plan it is
streamed. What is shown is this tile's own choice (gear menu → streamed. What is shown is this tile's own choice (gear menu →
@@ -12,8 +8,6 @@
(saved with the tile, 14 px until someone presses one); /admin only holds (saved with the tile, 14 px until someone presses one); /admin only holds
the connection settings. */ the connection settings. */
import createTeams from "./teams-client.js";
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>'; const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */ /* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
@@ -21,12 +15,6 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>'; const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
// Tiles added as "Teams" carry that variant and have their own settings.
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
return createPlan({ root, moduleApi });
}
function createPlan({ root, moduleApi }) {
let state = null; let state = null;
let skew = 0; // server clock − local clock, ms let skew = 0; // server clock − local clock, ms
let feedOffline = false; let feedOffline = false;
@@ -34,7 +22,6 @@ function createPlan({ root, moduleApi }) {
let ticker = null; let ticker = null;
let lastCurrentId = null; let lastCurrentId = null;
let lastListKey = ''; let lastListKey = '';
let absView = null; // what the server says AbleSet is doing, if anything
let userScrolledAt = 0; let userScrolledAt = 0;
root.innerHTML = ` root.innerHTML = `
@@ -494,133 +481,6 @@ function createPlan({ root, moduleApi }) {
renderLiveMarks(); renderLiveMarks();
} }
/* ── AbleSet: the song playing under the current item ──────────────
Everything here draws only when the server says there is an AbleSet
module, it is configured and reachable, and the song it is playing is
the plan item on screen. Any of those missing and the tile looks
exactly as it did before AbleSet existed. */
/** The playhead now, in beats, carried forward from the last packet. */
function absBeatsNow() {
if (!absView || absView.beats == null) return null;
if (!absView.playing || !absView.tempo) return absView.beats;
const since = Math.max(0, serverNow() - absView.at) / 1000;
return absView.beats + since * (absView.tempo / 60);
}
function absFraction(part) {
const beats = absBeatsNow();
if (beats == null || !part || part.start == null || part.end == null) return null;
const span = part.end - part.start;
if (!(span > 0)) return null;
return Math.max(0, Math.min(1, (beats - part.start) / span));
}
/** The row AbleSet's song belongs to — only ever the current one. */
function absRow() {
const a = absView;
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
if (a.itemId !== lastCurrentId) return null;
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
}
/** Keep the playing section visible in the strip — this strip, nothing else. */
function absScrollSections(strip) {
const active = strip.querySelector('.pp-abs-sec.is-now');
if (!active) return;
const box = strip.getBoundingClientRect();
const rect = active.getBoundingClientRect();
const delta = (rect.left - box.left) - (strip.clientWidth - rect.width) / 2;
if (Math.abs(delta) < 1) return;
strip.scrollTo({ left: strip.scrollLeft + delta, behavior: 'smooth' });
}
function renderAbleSet() {
const row = absRow();
// Anything left on a row that is no longer the one goes.
for (const stale of listEl.querySelectorAll('.pp-fill, .pp-abs-sections')) {
if (!row || !row.contains(stale)) stale.remove();
}
if (!row) return;
if (on('absProgress')) {
const fraction = absFraction(absView.song);
if (fraction == null) {
row.querySelector('.pp-fill')?.remove();
} else {
let fill = row.querySelector('.pp-fill');
if (!fill) {
fill = document.createElement('div');
fill.className = 'pp-fill';
row.prepend(fill); // behind the row's content, which the stylesheet lifts
}
fill.style.width = `${(fraction * 100).toFixed(2)}%`;
}
} else {
row.querySelector('.pp-fill')?.remove();
}
if (on('absSections') && absView.sections.length) {
const main = row.querySelector('.pp-col-main');
let strip = row.querySelector('.pp-abs-sections');
if (!strip && main) {
strip = document.createElement('div');
strip.className = 'pp-abs-sections';
main.appendChild(strip);
}
if (strip) {
// The chips are rebuilt only when the song's sections change. Moving
// on to the next section just moves the classes: rebuilding would
// throw the strip's scroll away thirteen times a song.
const key = absView.sections.map((s) => s.name).join('\u0000');
if (strip.dataset.key !== key) {
strip.dataset.key = key;
strip.dataset.now = '';
strip.innerHTML = '';
for (const section of absView.sections) {
const chip = document.createElement('span');
chip.className = 'pp-abs-sec';
chip.textContent = section.name;
strip.appendChild(chip);
}
}
const now = absView.section?.index ?? -1;
if (strip.dataset.now !== String(now)) {
strip.dataset.now = String(now);
[...strip.children].forEach((chip, i) => {
chip.classList.toggle('is-now', i === now);
chip.classList.toggle('is-done', i < now);
if (i !== now) chip.style.removeProperty('--pp-sec-fill');
});
requestAnimationFrame(() => absScrollSections(strip));
}
const active = strip.children[now];
if (active) {
const fraction = absFraction(absView.section);
active.style.setProperty('--pp-sec-fill', fraction == null ? '0%' : `${(fraction * 100).toFixed(1)}%`);
}
}
} else {
row.querySelector('.pp-abs-sections')?.remove();
}
}
/**
* Centre a row in this tile's own list. `scrollIntoView` would walk up and
* scroll every ancestor that can scroll with it, which on a dashboard taller
* than the window drags the whole board; moving `listEl.scrollTop` moves
* this tile and nothing else. Measured from rectangles rather than
* `offsetTop` because the rows' offset parent is the tile, not the list.
*/
function keepInView(row) {
const list = listEl.getBoundingClientRect();
const rect = row.getBoundingClientRect();
const delta = (rect.top - list.top) - (listEl.clientHeight - rect.height) / 2;
if (Math.abs(delta) < 1) return;
listEl.scrollTo({ top: listEl.scrollTop + delta, behavior: 'smooth' });
}
/** Current / done classes and the elapsed timers, without rebuilding rows. */ /** Current / done classes and the elapsed timers, without rebuilding rows. */
function renderLiveMarks() { function renderLiveMarks() {
const live = state?.live || {}; const live = state?.live || {};
@@ -659,12 +519,11 @@ function createPlan({ root, moduleApi }) {
} }
if (!h.endedAt) elapsedEl.classList.add('is-running'); if (!h.endedAt) elapsedEl.classList.add('is-running');
} }
renderAbleSet();
if (currentId !== lastCurrentId) { if (currentId !== lastCurrentId) {
lastCurrentId = currentId; lastCurrentId = currentId;
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) { if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`); const row = listEl.querySelector(`.pp-row[data-id="${CSS.escape(currentId)}"]`);
if (row) keepInView(row); row?.scrollIntoView({ block: 'center', behavior: 'smooth' });
} }
} }
} }
@@ -739,7 +598,6 @@ function createPlan({ root, moduleApi }) {
} }
function tick() { function tick() {
renderAbleSet();
if (!state?.plan) return; if (!state?.plan) return;
renderClock(); renderClock();
if (state.live?.following && on('showItemRuntime')) renderLiveMarks(); if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
@@ -751,7 +609,6 @@ function createPlan({ root, moduleApi }) {
if (!payload || typeof payload !== 'object' || !payload.state) return; if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state; state = payload.state;
if (Number.isFinite(payload.now)) skew = payload.now - Date.now(); if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
if (payload.ableset) absView = payload.ableset;
feedOffline = false; feedOffline = false;
render(); render();
} }
@@ -772,16 +629,6 @@ function createPlan({ root, moduleApi }) {
accept(JSON.parse(ev.data)); accept(JSON.parse(ev.data));
} catch { /* a bad frame is not fatal */ } } catch { /* a bad frame is not fatal */ }
}, },
// Its own event, several a second while a song plays: redraw that one
// row rather than the whole list.
ableset(ev) {
try {
absView = JSON.parse(ev.data);
} catch {
return;
}
if (state) renderAbleSet();
},
}, },
}); });
} }
+3 -242
View File
@@ -1,9 +1,9 @@
{ {
"id": "pco-plan", "id": "pco-plan",
"name": "PCO Plan", "name": "PCO Plan",
"version": "1.8.0", "version": "1.6.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Order of service and team assignments from Planning Center Services", "description": "Order of service from Planning Center Services",
"provides": [ "provides": [
"timers" "timers"
], ],
@@ -382,245 +382,6 @@
"label": "Match line", "label": "Match line",
"default": true, "default": true,
"group": "Show" "group": "Show"
},
"absProgress": {
"type": "boolean",
"label": "Song progress",
"default": true,
"group": "Show"
},
"absSections": {
"type": "boolean",
"label": "Song sections",
"default": true,
"group": "Show"
} }
}, }
"tiles": [
{
"id": "plan",
"name": "Plan",
"description": "The order of service, item by item",
"defaultSize": {
"w": 5,
"h": 6
},
"minSize": {
"w": 3,
"h": 3
}
},
{
"id": "teams",
"name": "Teams",
"description": "Who is scheduled, by team and position",
"defaultSize": {
"w": 4,
"h": 6
},
"minSize": {
"w": 2,
"h": 2
},
"instanceSchema": {
"columns": {
"type": "select",
"label": "Columns",
"default": "auto",
"options": [
{
"value": "auto",
"label": "Fit the tile"
},
{
"value": "1",
"label": "One"
},
{
"value": "2",
"label": "Two"
},
{
"value": "3",
"label": "Three"
}
]
},
"teams": {
"type": "multiselect",
"label": "Teams",
"columns": 2,
"allByDefault": true,
"optionsRoute": "/teams"
},
"headerTemplate": {
"type": "template",
"label": "Top bar text",
"default": "{serviceType} • {date} | {people} on {teams}",
"group": "Top bar",
"variables": [
{
"value": "{series}",
"label": "Series"
},
{
"value": "{part}",
"label": "Part / plan title"
},
{
"value": "{serviceType}",
"label": "Service type"
},
{
"value": "{date}",
"label": "Date"
},
{
"value": "{shortDate}",
"label": "Short date"
},
{
"value": "{time}",
"label": "Service time"
},
{
"value": "{people}",
"label": "People count"
},
{
"value": "{teams}",
"label": "Team count"
},
{
"value": "{confirmed}",
"label": "Confirmed count"
}
],
"help": "Use • between items and | to start a second line."
},
"teamColor": {
"type": "color",
"label": "Team headers",
"default": "#2ee59a",
"group": "Colors"
},
"tintTeams": {
"type": "boolean",
"label": "Tint teams",
"default": false,
"group": "Colors"
},
"tintRows": {
"type": "boolean",
"label": "Tint rows too",
"default": false,
"group": "Colors",
"showWhen": "tintTeams"
},
"headerGradient": {
"type": "boolean",
"label": "Header fade",
"default": true,
"group": "Colors"
},
"confirmedColor": {
"type": "color",
"label": "Confirmed",
"default": "#2ee59a",
"group": "Colors"
},
"unconfirmedColor": {
"type": "color",
"label": "Unconfirmed",
"default": "#f0b429",
"group": "Colors"
},
"declinedColor": {
"type": "color",
"label": "Declined",
"default": "#f4433c",
"group": "Colors"
},
"showTopBar": {
"type": "boolean",
"label": "Top bar",
"default": true,
"group": "Show"
},
"showTeamHeaders": {
"type": "boolean",
"label": "Team headers",
"default": true,
"group": "Show"
},
"showTeamCounts": {
"type": "boolean",
"label": "Team counts",
"default": true,
"group": "Show"
},
"showAvatars": {
"type": "boolean",
"label": "Avatars",
"default": true,
"group": "Show"
},
"showPosition": {
"type": "boolean",
"label": "Positions",
"default": true,
"group": "Show"
},
"groupByPosition": {
"type": "boolean",
"label": "Group by position",
"default": false,
"group": "Show"
},
"showStatus": {
"type": "boolean",
"label": "Status",
"default": true,
"group": "Show"
},
"showNotes": {
"type": "boolean",
"label": "Person notes",
"default": false,
"group": "Show"
},
"showDeclined": {
"type": "boolean",
"label": "Declined people",
"default": true,
"group": "Show"
},
"showUnconfirmed": {
"type": "boolean",
"label": "Unconfirmed people",
"default": true,
"group": "Show"
},
"showEmptyTeams": {
"type": "boolean",
"label": "Teams with nobody",
"default": false,
"group": "Show"
}
},
"instanceGroups": {
"Top bar": {
"collapsed": false
},
"Colors": {
"collapsed": false,
"columns": 2
},
"Show": {
"collapsed": true,
"columns": 2
}
}
}
]
} }
+2 -521
View File
@@ -9,10 +9,6 @@
* *
* GET /state → { state, now } (instant paint for new tiles) * GET /state → { state, now } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated) * GET /stream → SSE, `state` events (change-detected, heartbeated)
* plus `ableset` events: the song AbleSet is playing, its
* sections and playhead, when there is an AbleSet module
* to follow (see below). Its own event so the playhead
* never drags the plan onto the wire with it.
* POST /refresh → re-read the plan now * POST /refresh → re-read the plan now
* GET /service-types → { options } for the admin "Service type" select, * GET /service-types → { options } for the admin "Service type" select,
* grouped by Planning Center folder path * grouped by Planning Center folder path
@@ -21,8 +17,6 @@
* (documents ProPresenter has shown or lists — see below) * (documents ProPresenter has shown or lists — see below)
* GET /note-categories → { options } for a tile's "Item notes" categories: * GET /note-categories → { options } for a tile's "Item notes" categories:
* the service type's item note categories, by name * the service type's item note categories, by name
* GET /teams → { options } for a Teams tile's "Teams" switches
* GET /team-avatar/<planPersonId> → that person's photo, proxied + cached
* GET /timers → snapshot for the Timers module (guide: "Offering * GET /timers → snapshot for the Timers module (guide: "Offering
* GET /timers/stream → SSE, `timers` events timers to the Timers module") * GET /timers/stream → SSE, `timers` events timers to the Timers module")
* *
@@ -35,14 +29,6 @@
* GET …/plans/{id}/plan_times (service start times) * GET …/plans/{id}/plan_times (service start times)
* GET …/plans/{id}/items?include=song,arrangement,item_notes * GET …/plans/{id}/items?include=song,arrangement,item_notes
* GET /service_types/{st}/item_note_categories (note category names) * GET /service_types/{st}/item_note_categories (note category names)
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
*
* Following AbleSet: the same loopback trick, entirely optional. When this
* ProdDash has an AbleSet module that is configured and answering, its song,
* sections and playhead are passed to the tiles, and the song is matched to a
* plan item by name so a tile can fill that item as the song plays. With no
* such module — not installed, disabled, no host — tiles are told
* `available: false` and draw none of it.
* *
* 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
@@ -70,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 { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching'); const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
const { const {
MINUTE, MINUTE,
serviceTimes, serviceTimes,
@@ -96,28 +82,9 @@ const HEARTBEAT_MS = 15000;
/** The Now/Next module whose feed we follow. */ /** The Now/Next module whose feed we follow. */
const NOW_NEXT_ID = 'propresenter-now-next'; const NOW_NEXT_ID = 'propresenter-now-next';
const NOW_NEXT_RETRY_MS = 3000; const NOW_NEXT_RETRY_MS = 3000;
/** The AbleSet module, followed the same way — entirely optional (see below). */
const ABLESET_ID = 'ableset';
const ABLESET_RETRY_MS = 3000;
const ABLESET_MISSING_RETRY_MS = 15000;
/** Fastest the playhead is passed on; AbleSet offers more than a tile can draw. */
const ABLESET_POS_MS = 200;
const NOW_NEXT_MISSING_RETRY_MS = 15000; const NOW_NEXT_MISSING_RETRY_MS = 15000;
/** How many distinct documents seen live are remembered for the admin selects. */ /** How many distinct documents seen live are remembered for the admin selects. */
const SEEN_DOCUMENTS_MAX = 200; const SEEN_DOCUMENTS_MAX = 200;
/**
* Team assignments change far more slowly than the plan itself (someone
* confirms, a position is filled), so they ride a slow lane: re-read when the
* plan changes, and otherwise no more often than this.
*/
const TEAMS_MIN_MS = 60 * 1000;
/** How long the service type's own team list is believed. */
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
const AVATAR_CACHE_MAX = 300;
const AVATAR_TTL_MS = 6 * 60 * 60 * 1000;
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
/** How long a service type's item note categories are believed before Planning Center is asked again. */ /** How long a service type's item note categories are believed before Planning Center is asked again. */
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE; const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
/** /**
@@ -145,8 +112,6 @@ function clearedState() {
serviceType: { id: '', name: '' }, serviceType: { id: '', name: '' },
plan: null, plan: null,
items: [], items: [],
teams: [], // who is scheduled, by team (the Teams tile)
teamsError: '', // why the last team read failed, if it did
live: clearedLive(), live: clearedLive(),
service: clearedService(), service: clearedService(),
timing: null, // the admin's service-timing choices (no secrets) timing: null, // the admin's service-timing choices (no secrets)
@@ -170,28 +135,6 @@ function clearedLive() {
}; };
} }
/**
* What tiles are told about AbleSet. `available` false covers every way there
* may be nothing to draw — no module installed, disabled, or not answering —
* and the tiles then show none of it at all.
*/
function clearedAbleSet() {
return {
available: false,
configured: false,
reachable: false,
reason: '',
playing: false,
tempo: null,
song: null,
section: null,
sections: [],
beats: null,
itemId: '',
at: 0,
};
}
/** The service that owns the clock, as tiles and the timers see it. */ /** The service that owns the clock, as tiles and the timers see it. */
function clearedService() { function clearedService() {
return { return {
@@ -429,168 +372,6 @@ function shapePlan(planRec, serviceTypeId, planTimes) {
}; };
} }
/* ── team assignments (the Teams tile) ──────────────────────────────── */
/**
* Planning Center's scheduling status letters. The API answers "C", "U" or
* "D"; some accounts and older records spell the word out, so match on the
* first letter and treat anything unknown as not yet confirmed — the state a
* booth should chase.
*/
function teamStatusOf(raw) {
const s = String(raw || '').trim().toUpperCase();
if (s.startsWith('C')) return 'confirmed';
if (s.startsWith('D')) return 'declined';
return 'unconfirmed';
}
/**
* The person's photo. A PlanPerson usually carries `photo_thumbnail`; when a
* plan is read with `include=person` the Person record carries the fuller set
* of names Planning Center has used for the same thing over the years. Take
* the first that looks like a URL — a missing photo is normal and the tile
* draws initials instead.
*/
function teamPhotoOf(attrs, person) {
const candidates = [
attrs?.photo_thumbnail,
attrs?.photo_url,
person?.attributes?.avatar,
person?.attributes?.photo_thumbnail_url,
person?.attributes?.photo_url,
person?.attributes?.thumbnail_url,
];
for (const c of candidates) {
const url = String(c || '').trim();
if (/^https?:\/\//i.test(url)) return url;
}
return '';
}
/**
* `/plans/{id}/team_members?include=team,person` → the teams a tile draws,
* each with its people. Teams keep Planning Center's own order (a Team's
* `sequence`, then name); people sort by position, then by name, so a tile
* reads the way the schedule page does.
*
* Everyone scheduled belongs to a team, but a record whose team relationship
* is missing (a deleted team, a partial response) is not dropped on the
* floor — it lands under "Unassigned" where somebody will notice it.
*
* `allTeams` is the service type's own team list. Planning Center only
* returns a team here once somebody is scheduled on it, so without that list
* a team nobody has signed up for is invisible — which is the one a booth
* most wants to see. It also carries the authoritative name and order.
*/
function shapeTeams(body, allTeams) {
const included = new Map();
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
const teams = new Map();
for (const pp of body.data || []) {
const a = pp.attributes || {};
const teamRef = pp.relationships?.team?.data;
const teamRec = teamRef ? included.get(`Team/${teamRef.id}`) : null;
const teamId = teamRef?.id ? String(teamRef.id) : 'unassigned';
if (!teams.has(teamId)) {
teams.set(teamId, {
id: teamId,
name: String(teamRec?.attributes?.name || '').trim() || (teamRef?.id ? `Team ${teamId}` : 'Unassigned'),
sequence: Number(teamRec?.attributes?.sequence) || 0,
members: [],
});
}
const personRef = pp.relationships?.person?.data;
const personRec = personRef ? included.get(`Person/${personRef.id}`) : null;
teams.get(teamId).members.push({
id: String(pp.id),
personId: personRef?.id ? String(personRef.id) : '',
name: String(a.name || personRec?.attributes?.name || '').trim() || 'Unnamed',
position: String(a.team_position_name || '').trim(),
status: teamStatusOf(a.status),
declineReason: String(a.decline_reason || '').trim(),
notes: String(a.notes || '').trim(),
// The URL itself stays on the server; the tile asks for /team-avatar/<id>.
hasPhoto: Boolean(teamPhotoOf(a, personRec)),
});
}
// Teams nobody is scheduled on yet, and the real name/order for the rest.
for (const t of allTeams || []) {
const known = teams.get(t.id);
if (known) {
if (t.name) known.name = t.name;
known.sequence = t.sequence;
} else {
teams.set(t.id, { id: t.id, name: t.name, sequence: t.sequence, members: [] });
}
}
const out = [...teams.values()];
out.sort((x, y) => x.sequence - y.sequence || x.name.localeCompare(y.name));
for (const team of out) {
team.members.sort((x, y) => x.position.localeCompare(y.position) || x.name.localeCompare(y.name));
team.counts = {
total: team.members.length,
confirmed: team.members.filter((m) => m.status === 'confirmed').length,
unconfirmed: team.members.filter((m) => m.status === 'unconfirmed').length,
declined: team.members.filter((m) => m.status === 'declined').length,
};
}
return out;
}
/* Avatars: fetched once, kept a while, and served from ProdDash so no browser
ever talks to Planning Center (same reasoning as every other upstream). */
const avatarCache = new Map(); // url → { buf, type, at } — survives a re-init
const avatarInFlight = new Map();
function fetchAvatar(url) {
const hit = avatarCache.get(url);
if (hit && Date.now() - hit.at < AVATAR_TTL_MS) return Promise.resolve(hit);
if (avatarInFlight.has(url)) return avatarInFlight.get(url);
const p = (async () => {
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: 'follow' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const type = String(response.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
if (!type.startsWith('image/')) throw new Error(`not an image (${type})`);
const buf = Buffer.from(await response.arrayBuffer());
if (buf.length > AVATAR_MAX_BYTES) throw new Error('too large');
if (avatarCache.size >= AVATAR_CACHE_MAX) avatarCache.delete(avatarCache.keys().next().value);
const entry = { buf, type, at: Date.now() };
avatarCache.set(url, entry);
return entry;
})().finally(() => avatarInFlight.delete(url));
avatarInFlight.set(url, p);
return p;
}
/** https anywhere, or plain http on this machine — which is the mock, nothing else. */
function avatarUrlAllowed(url) {
try {
const u = new URL(String(url || ''));
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname));
} catch {
return false;
}
}
/** Answer with the photo at `url`; a failure is a 404/502 and the tile draws initials. */
async function serveAvatar(res, url) {
if (!avatarUrlAllowed(url)) {
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
return void res.end('No photo');
}
try {
const img = await fetchAvatar(url);
res.writeHead(200, { 'Content-Type': img.type, 'Content-Length': img.buf.length, 'Cache-Control': 'private, max-age=3600' });
res.end(img.buf);
} catch {
res.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
res.end('Photo unavailable');
}
}
/** "9:00 service" / "9:00 First Service" — how a service is named in details and logs. */ /** "9:00 service" / "9:00 First Service" — how a service is named in details and logs. */
function serviceLabel(win) { function serviceLabel(win) {
if (!win || !win.startsAt) return ''; if (!win || !win.startsAt) return '';
@@ -768,98 +549,6 @@ module.exports = {
} }
} }
/* — team assignments (the Teams tile) — */
/**
* The service type's teams, cached: they change when somebody edits the
* service type, not during a service. A failed read is not fatal — the
* plan's own teams still draw, just without the empty ones.
*/
let serviceTeams = { at: 0, list: [] };
async function readServiceTeams() {
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
try {
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/teams`);
const list = (body.data || []).map((t, i) => ({
id: String(t.id),
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
sequence: Number(t.attributes?.sequence) || i + 1,
}));
serviceTeams = { at: Date.now(), list };
} catch (err) {
log(`service type team list read failed: ${err?.message || err}`);
serviceTeams.at = Date.now(); // don't retry every poll
}
return serviceTeams.list;
}
/** planPersonId → photo URL, so a browser never learns the CDN address. */
let teamPhotos = new Map();
let teamsReadAt = 0;
/**
* Who is scheduled on this plan. Asked for with `include=team,person` and
* read again defensively without the includes when an account refuses
* them, exactly as the items read does: a tile that can name people
* without photos is far better than a tile that shows nothing.
*/
async function readTeams(planId) {
const target = `${planPath(planId)}/team_members`;
let body;
try {
body = await pco.getAll(target, { include: 'team,person' });
} catch (err) {
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
log(`team include rejected (${err.message}) — reading team members without includes`);
body = await pco.getAll(target);
} else {
throw err;
}
}
const teams = shapeTeams(body, await readServiceTeams());
const photos = new Map();
const included = new Map();
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
for (const pp of body.data || []) {
const personRef = pp.relationships?.person?.data;
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
if (url) photos.set(String(pp.id), url);
}
teamPhotos = photos;
teamsReadAt = Date.now();
return teams;
}
/**
* Refresh the team list when it is worth it: a new plan, or the slow lane
* coming round. A failure keeps the last good list and says so in the
* state — the plan itself is what the booth needs, and losing the roster
* must never cost them the running order.
*/
async function refreshTeams(planId, force) {
if (!force && Date.now() - teamsReadAt < TEAMS_MIN_MS) return;
try {
const teams = await readTeams(planId);
const before = state.teams.reduce((n, t) => n + t.members.length, 0);
const after = teams.reduce((n, t) => n + t.members.length, 0);
state.teams = teams;
state.teamsError = '';
if (force || before !== after) {
log(`teams: ${after} scheduled across ${teams.length} team${teams.length === 1 ? '' : 's'}`);
}
} catch (err) {
state.teamsError = err?.message || String(err);
log(`team read failed: ${state.teamsError}`);
}
}
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
function teamOptions() {
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
}
async function readPlan() { async function readPlan() {
if (!pco.configured || !serviceTypeId) return; if (!pco.configured || !serviceTypeId) return;
let planRec = null; let planRec = null;
@@ -884,8 +573,6 @@ module.exports = {
if (state.plan) log('no plan found any more — service timing reset'); if (state.plan) log('no plan found any more — service timing reset');
state.plan = null; state.plan = null;
state.items = []; state.items = [];
state.teams = [];
state.teamsError = "";
state.reachable = true; state.reachable = true;
state.lastError = ''; state.lastError = '';
state.lastFetched = Date.now(); state.lastFetched = Date.now();
@@ -902,8 +589,6 @@ module.exports = {
state.plan = plan; state.plan = plan;
state.items = items; state.items = items;
noteMessageItem(items); noteMessageItem(items);
// The roster rides its own slow lane and never fails the plan read.
await refreshTeams(plan.id, planChanged);
state.reachable = true; state.reachable = true;
state.lastError = ''; state.lastError = '';
state.lastFetched = Date.now(); state.lastFetched = Date.now();
@@ -1297,170 +982,6 @@ module.exports = {
const badPorts = new Set(); const badPorts = new Set();
/** Fallback only: a browser reached us on this Host — normally the shell's real port. */ /** Fallback only: a browser reached us on this Host — normally the shell's real port. */
/* — AbleSet, over loopback like the ProPresenter feed above — */
/**
* The AbleSet module, if this ProdDash has one. Nothing here is required:
* a dashboard with no AbleSet module, a disabled one, or one with no host
* configured simply reports `available: false` and the tiles draw none of
* it. Installing AbleSet later lights it up without a restart, because a
* 404 is retried on the slow timer like any other missing feed.
*
* What tiles get is deliberately small — the song, its sections, and the
* playhead in beats with a stamp — because it moves five times a second
* and must never drag the plan (items, teams) onto the wire with it. It
* rides its own `ableset` event on the module's one stream.
*/
let absRequest = null;
let absRetryTimer = null;
let absPosTimer = null;
let absLastSent = 0;
let absLastStill = '';
let abs = clearedAbleSet();
function absPart(p) {
if (!p || typeof p !== 'object') return null;
const name = String(p.name || '').trim();
if (!name) return null;
const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
return { name, index: n(p.index), start: n(p.start), end: n(p.end) };
}
/**
* Which plan item AbleSet's song is, by the same name matching the live
* ProPresenter document uses. Without this a progress bar would happily
* fill the sermon while the band rehearses the next song: the tile only
* draws progress when the song AbleSet is playing IS the item on screen.
*/
function absItemFor(songName) {
if (!songName) return '';
let best = null;
for (const item of state.items) {
if (item.itemType === 'header') continue;
const score = Math.max(
similarity(songName, item.title),
item.song ? similarity(songName, item.song.title) : 0
);
if (score >= MATCH_THRESHOLD && (!best || score > best.score)) best = { id: item.id, score };
}
return best ? best.id : '';
}
function applyAbleSet(view) {
const song = absPart(view?.song);
abs = {
available: true,
configured: Boolean(view?.configured),
reachable: Boolean(view?.reachable),
playing: Boolean(view?.playing),
tempo: Number.isFinite(Number(view?.tempo)) ? Number(view.tempo) : null,
song,
section: absPart(view?.section),
sections: Array.isArray(view?.sections)
? view.sections.slice(0, 60).map((s) => ({ name: String(s?.name || ''), color: s?.color || null }))
: [],
beats: Number.isFinite(Number(view?.pos?.beats)) ? Number(view.pos.beats) : null,
itemId: song ? absItemFor(song.name) : '',
at: Date.now(),
};
broadcastAbleSet();
}
/** Only the playhead moved: keep everything else and restamp. */
function applyAbleSetPos(pos) {
if (!abs.available) return;
abs = {
...abs,
beats: Number.isFinite(Number(pos?.beats)) ? Number(pos.beats) : abs.beats,
at: Date.now(),
};
broadcastAbleSet();
}
/**
* Fan the slice out. A change in anything but the playhead goes at once;
* the playhead itself is throttled, since AbleSet offers more packets
* than any tile can draw.
*/
function broadcastAbleSet(force) {
const { beats, at, ...still } = abs;
const frame = JSON.stringify(still);
const changed = frame !== absLastStill;
absLastStill = frame;
const now = Date.now();
if (!force && !changed && now - absLastSent < ABLESET_POS_MS) {
// Due soon anyway — make sure the last packet of a burst still lands.
if (!absPosTimer) {
absPosTimer = setTimeout(() => { absPosTimer = null; broadcastAbleSet(true); }, ABLESET_POS_MS);
absPosTimer.unref?.();
}
return;
}
clearTimeout(absPosTimer);
absPosTimer = null;
absLastSent = now;
if (!streams.size) return;
writeAll(streams, `event: ableset\ndata: ${JSON.stringify(abs)}\n\n`);
}
function absUnavailable(reason, retryMs) {
if (abs.available || abs.reason !== reason) {
abs = { ...clearedAbleSet(), reason };
broadcastAbleSet(true);
}
if (stopped) return;
clearTimeout(absRetryTimer);
absRetryTimer = setTimeout(connectAbleSet, retryMs);
absRetryTimer.unref?.();
}
function connectAbleSet() {
if (stopped) return;
const port = nnPort;
const req = http.get(
{ host: '127.0.0.1', port, path: `/api/modules/${ABLESET_ID}/stream`, headers: { Accept: 'text/event-stream' } },
(res) => {
if (res.statusCode !== 200) {
res.resume();
return absUnavailable(
res.statusCode === 404 ? 'no AbleSet module here' : `AbleSet module feed answered ${res.statusCode}`,
ABLESET_MISSING_RETRY_MS
);
}
res.setEncoding('utf8');
let buffer = '';
res.on('data', (chunk) => {
buffer += chunk;
let idx;
while ((idx = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = 'message';
let data = '';
for (const line of block.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (!data) continue;
try {
if (event === 'state') applyAbleSet(JSON.parse(data).state ?? JSON.parse(data));
else if (event === 'pos') applyAbleSetPos(JSON.parse(data));
} catch { /* a bad frame is not fatal */ }
}
});
res.on('end', () => absUnavailable('AbleSet module feed ended', ABLESET_RETRY_MS));
res.on('error', () => absUnavailable('AbleSet module feed dropped', ABLESET_RETRY_MS));
}
);
req.on('error', (err) => {
if (req !== absRequest) return; // superseded by a reconnect on the learned port
absUnavailable(`ProdDash loopback failed: ${err.message}`, ABLESET_RETRY_MS);
});
req.setTimeout(45000, () => req.destroy(new Error('feed went silent')));
absRequest = req;
}
function learnPort(hostHeader) { function learnPort(hostHeader) {
if (shellPort) return; // the shell told us; nothing to learn if (shellPort) return; // the shell told us; nothing to learn
const port = portFromHost(hostHeader); const port = portFromHost(hostHeader);
@@ -1472,10 +993,6 @@ module.exports = {
try { nnRequest?.destroy(); } catch { /* gone */ } try { nnRequest?.destroy(); } catch { /* gone */ }
nnRequest = null; nnRequest = null;
connectNowNext(); connectNowNext();
clearTimeout(absRetryTimer);
try { absRequest?.destroy(); } catch { /* gone */ }
absRequest = null;
connectAbleSet();
} }
/** /**
@@ -1734,9 +1251,6 @@ module.exports = {
state.live.reason = 'Connecting to the ProPresenter module feed'; state.live.reason = 'Connecting to the ProPresenter module feed';
connectNowNext(); connectNowNext();
} }
// Always: whether there is an AbleSet module to follow is its answer, not
// an admin setting here.
connectAbleSet();
mirrorService(); mirrorService();
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
@@ -1769,7 +1283,6 @@ module.exports = {
timerStreams, timerStreams,
pco, pco,
getState: () => state, getState: () => state,
getAbleSet: () => abs,
learnPort, learnPort,
refresh() { refresh() {
clearTimeout(pollTimer); clearTimeout(pollTimer);
@@ -1780,8 +1293,6 @@ module.exports = {
timersSnapshot, timersSnapshot,
documentOptions, documentOptions,
noteCategoryOptions, noteCategoryOptions,
teamOptions,
avatarUrlFor: (id) => teamPhotos.get(String(id)) || "",
}; };
return { return {
@@ -1791,10 +1302,7 @@ module.exports = {
clearInterval(ticker); clearInterval(ticker);
clearTimeout(pollTimer); clearTimeout(pollTimer);
clearTimeout(nnRetryTimer); clearTimeout(nnRetryTimer);
clearTimeout(absRetryTimer);
clearTimeout(absPosTimer);
try { nnRequest?.destroy(); } catch { /* gone */ } try { nnRequest?.destroy(); } catch { /* gone */ }
try { absRequest?.destroy(); } catch { /* gone */ }
for (const set of [streams, timerStreams]) { for (const set of [streams, timerStreams]) {
for (const res of set) { for (const res of set) {
try { res.end(); } catch { /* already gone */ } try { res.end(); } catch { /* already gone */ }
@@ -1836,11 +1344,7 @@ module.exports = {
return { return {
'GET /state': (req, res) => { 'GET /state': (req, res) => {
current?.learnPort(req.headers.host); current?.learnPort(req.headers.host);
json(res, 200, { json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
state: current ? current.getState() : clearedState(),
ableset: current ? current.getAbleSet() : clearedAbleSet(),
now: Date.now(),
});
}, },
'GET /stream': (req, res) => { 'GET /stream': (req, res) => {
@@ -1848,7 +1352,6 @@ module.exports = {
sseHead(res); sseHead(res);
const state = current ? current.getState() : clearedState(); const state = current ? current.getState() : clearedState();
res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`); res.write(`event: state\ndata: ${JSON.stringify({ state, now: Date.now() })}\n\n`);
res.write(`event: ableset\ndata: ${JSON.stringify(current ? current.getAbleSet() : clearedAbleSet())}\n\n`);
if (!current) return void res.end(); if (!current) return void res.end();
const { streams } = current; const { streams } = current;
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
@@ -1882,28 +1385,6 @@ module.exports = {
req.on('close', () => timerStreams.delete(res)); req.on('close', () => timerStreams.delete(res));
}, },
/**
* Options for a Teams tile's "Teams" switches. Like the note
* categories, always a 200 with a list: an empty one simply means there
* is nothing scheduled to choose from yet, which the gear window says
* plainly.
*/
'GET /teams': (req, res) => {
json(res, 200, current ? current.teamOptions() : { options: [] });
},
/**
* A scheduled person's photo, proxied from Planning Center and cached.
* The tile asks by plan-person id and never sees the CDN URL; an id the
* module does not know (an old tile, a person since unscheduled) is a
* 404 and the tile falls back to initials.
*/
'GET /team-avatar/*': (req, res) => {
const id = String(req.wildcard || '').replace(/^\/+/, '');
const url = current ? current.avatarUrlFor(id) : '';
serveAvatar(res, url);
},
/** Options for the admin "Start document" / "End document" selects. */ /** Options for the admin "Start document" / "End document" selects. */
'GET /documents': (req, res) => { 'GET /documents': (req, res) => {
if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' }); if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' });
-292
View File
@@ -86,7 +86,6 @@
} }
[data-module="pco-plan"] .pp-row { [data-module="pco-plan"] .pp-row {
position: relative;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.6em; gap: 0.6em;
@@ -366,294 +365,3 @@
[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); } [data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); }
[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); } [data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); }
[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); } [data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); }
/* ── the Teams tile ───────────────────────────────────────────────────
Who is scheduled, by team. Sizes hang off --pp-size like the Plan tile;
colours come from the tile's own settings (--tm-team per section,
--tm-status per person) over the shell's palette. */
[data-module="pco-plan"] .tm-wrap {
--pp-size: 14px;
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
font-size: var(--pp-size);
color: var(--text);
}
[data-module="pco-plan"] .tm-head {
flex: none;
padding: 0.6em 0.85em 0.5em;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
[data-module="pco-plan"] .tm-head[hidden] { display: none; }
[data-module="pco-plan"] .tm-title {
font-size: 1.1em;
font-weight: 700;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-sub {
margin-top: 0.15em;
font-size: 0.82em;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-sub[hidden] { display: none; }
[data-module="pco-plan"] .tm-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0 0.6em;
overscroll-behavior: contain;
}
/* ── a team ── */
[data-module="pco-plan"] .tm-team { --tm-team: var(--accent); }
/* The header band runs edge to edge, like the Plan tile's section headers. */
[data-module="pco-plan"] .tm-team-head {
display: flex;
align-items: center;
gap: 0.6em;
padding: 0.45em 0.9em 0.4em;
margin-top: 0.7em;
background: color-mix(in srgb, var(--tm-team) 22%, transparent);
}
[data-module="pco-plan"] .tm-team:first-child .tm-team-head { margin-top: 0; }
[data-module="pco-plan"] .header-gradient .tm-team-head {
background: linear-gradient(90deg,
color-mix(in srgb, var(--tm-team) 30%, transparent),
color-mix(in srgb, var(--tm-team) 6%, transparent) 70%,
transparent);
}
[data-module="pco-plan"] .tm-team-name {
flex: 1;
min-width: 0;
font-size: 0.85em;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-team-count {
flex: none;
font-size: 0.78em;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
[data-module="pco-plan"] .tm-people {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11em, 1fr));
gap: 0.15em 0.5em;
padding: 0.35em 0.6em 0.2em;
}
[data-module="pco-plan"] .tm-people.is-fixed-cols {
grid-template-columns: repeat(var(--tm-cols, 1), minmax(0, 1fr));
}
[data-module="pco-plan"] .tm-people.is-sub { padding-top: 0.1em; padding-bottom: 0.1em; }
/* Grouped by position: headings span, each position's people form their own grid. */
[data-module="pco-plan"] .tm-groups { padding-bottom: 0.2em; }
[data-module="pco-plan"] .tm-position-head {
padding: 0.4em 0.9em 0;
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="pco-plan"] .tm-team-empty {
grid-column: 1 / -1;
padding: 0.2em 0.3em 0.4em;
font-size: 0.85em;
font-style: italic;
color: var(--muted);
}
/* ── one person ── */
[data-module="pco-plan"] .tm-person {
display: flex;
align-items: center;
gap: 0.55em;
min-width: 0;
padding: 0.3em 0.4em;
border-radius: 0.45em;
}
[data-module="pco-plan"] .tm-person:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }
[data-module="pco-plan"] .tint-rows .tm-person { background: color-mix(in srgb, var(--tm-team) 8%, transparent); }
[data-module="pco-plan"] .tint-rows .tm-person:hover { background: color-mix(in srgb, var(--tm-team) 16%, transparent); }
[data-module="pco-plan"] .tm-person.is-declined { opacity: 0.55; }
[data-module="pco-plan"] .tm-avatar {
position: relative;
flex: none;
width: 2.1em;
height: 2.1em;
border-radius: 999px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--tm-team) 28%, var(--panel));
color: var(--text);
font-size: 0.78em;
font-weight: 800;
letter-spacing: 0.02em;
user-select: none;
}
/* The photo covers the initials; if it fails to load the script drops it. */
[data-module="pco-plan"] .tm-avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
background: var(--panel);
}
[data-module="pco-plan"] .tm-person-main { flex: 1; min-width: 0; }
[data-module="pco-plan"] .tm-name {
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-person.is-declined .tm-name { text-decoration: line-through; }
[data-module="pco-plan"] .tm-position {
font-size: 0.8em;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="pco-plan"] .tm-note {
margin-top: 0.1em;
font-size: 0.78em;
line-height: 1.3;
color: color-mix(in srgb, var(--text) 75%, var(--muted));
overflow-wrap: anywhere;
}
[data-module="pco-plan"] .tm-status {
flex: none;
width: 0.55em;
height: 0.55em;
border-radius: 999px;
background: var(--tm-status, var(--muted));
box-shadow: 0 0 0 0.18em color-mix(in srgb, var(--tm-status, var(--muted)) 18%, transparent);
}
/* ── empty / degraded ── */
[data-module="pco-plan"] .tm-empty {
padding: 12% 1.2em 1em;
text-align: center;
color: var(--muted);
}
[data-module="pco-plan"] .tm-empty-title { font-size: 1.1em; font-weight: 700; color: var(--text); }
[data-module="pco-plan"] .tm-empty-hint { margin-top: 0.4em; font-size: 0.9em; line-height: 1.4; }
[data-module="pco-plan"] .tm-empty-link {
display: inline-block;
margin-top: 0.9em;
padding: 0.35em 0.9em;
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
border-radius: 999px;
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
[data-module="pco-plan"] .tm-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
[data-module="pco-plan"] .tm-wrap.is-offline .tm-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
[data-module="pco-plan"] .tm-wrap.is-offline .tm-body { opacity: 0.6; }
/* ── AbleSet under the current item ───────────────────────────────────
Drawn only while an AbleSet module is configured, reachable, and playing
the song this item is. The fill is a layer behind the row's own content;
everything else in the row is lifted over it. */
[data-module="pco-plan"] .pp-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 0;
border-radius: 0.5em 0 0 0.5em;
background: color-mix(in srgb, var(--pp-current) 26%, transparent);
/* Packets arrive about five times a second; the glide covers the gaps. */
transition: width 0.22s linear;
pointer-events: none;
}
[data-module="pco-plan"] .pp-row > *:not(.pp-fill) { position: relative; z-index: 1; }
/* The sections strip, bottom right of the current item. */
[data-module="pco-plan"] .pp-abs-sections {
display: flex;
gap: 0.3em;
/* Bottom right of the item: hug the content and sit against the right edge
until there are more sections than fit, when it fills the width and
scrolls instead. */
width: fit-content;
max-width: 100%;
margin-top: 0.35em;
margin-left: auto;
padding-bottom: 0.1em;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
overscroll-behavior: contain;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
mask-image: linear-gradient(90deg, transparent, #000 1.2em, #000 calc(100% - 1.2em), transparent);
}
[data-module="pco-plan"] .pp-abs-sections::-webkit-scrollbar { display: none; }
[data-module="pco-plan"] .pp-abs-sec {
flex: none;
padding: 0.1em 0.5em;
border-radius: 999px;
border: 1px solid var(--border);
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
color: var(--muted);
transition: color 0.2s ease, border-color 0.2s ease;
}
[data-module="pco-plan"] .pp-abs-sec.is-done { opacity: 0.45; }
/* The section now playing fills left to right as it goes, like the row. */
[data-module="pco-plan"] .pp-abs-sec.is-now {
color: var(--text);
border-color: color-mix(in srgb, var(--pp-current) 60%, transparent);
background:
linear-gradient(90deg,
color-mix(in srgb, var(--pp-current) 40%, transparent) var(--pp-sec-fill, 0%),
transparent var(--pp-sec-fill, 0%));
}
-435
View File
@@ -1,435 +0,0 @@
/* PCO Plan — the Teams tile.
Who is scheduled on the current plan, by team and position, from the same
server state the Plan tile draws (state.teams). Everything shown is this
tile's own choice (gear menu): which teams and positions, what each row
carries, and the colours. Photos come from ProdDash's own avatar route —
the browser never talks to Planning Center. */
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
export default function createTeams({ root, moduleApi }) {
let state = null;
let feedOffline = false;
let stream = null;
let lastKey = '';
root.innerHTML = `
<div class="tm-wrap">
<header class="tm-head">
<div class="tm-title"></div>
<div class="tm-sub"></div>
</header>
<div class="tm-body"></div>
</div>`;
const wrap = root.querySelector('.tm-wrap');
const headEl = root.querySelector('.tm-head');
const titleEl = root.querySelector('.tm-title');
const subEl = root.querySelector('.tm-sub');
const bodyEl = root.querySelector('.tm-body');
const cfg = () => moduleApi.instanceSettings || {};
const on = (key) => cfg()[key] !== false; // unset = shown
/* ── title-bar controls ─────────────────────────────────────────── */
const refreshBtn = moduleApi.header.addButton({
icon: REFRESH_SVG,
title: 'Re-read from Planning Center',
onClick() {
refreshBtn.classList.add('active');
moduleApi.fetch('/refresh', { method: 'POST' })
.catch(() => { /* the stream reports the outcome */ })
.finally(() => refreshBtn.classList.remove('active'));
},
});
/* ── helpers ────────────────────────────────────────────────────── */
function colorOf(value, fallback) {
const s = String(value || '').trim();
return /^#[0-9a-fA-F]{3,8}$/.test(s) ? s : fallback;
}
/**
* A team's colour when "Tint teams" is on. Taken from the team's position
* in the plan, not a hash of its name: neighbouring teams then always look
* different, which is the whole point of tinting them.
*/
function teamColor(index) {
if (!cfg().tintTeams) return colorOf(cfg().teamColor, '#2ee59a');
return `hsl(${TEAM_HUES[index % TEAM_HUES.length]} 70% 55%)`;
}
function statusColor(status) {
if (status === 'confirmed') return colorOf(cfg().confirmedColor, '#2ee59a');
if (status === 'declined') return colorOf(cfg().declinedColor, '#f4433c');
return colorOf(cfg().unconfirmedColor, '#f0b429');
}
const STATUS_LABEL = { confirmed: 'Confirmed', unconfirmed: 'Unconfirmed', declined: 'Declined' };
/** "Sarah Miles" → "SM"; one word → its first two letters. */
function initialsOf(name) {
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!words.length) return '?';
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
/** A saved multiselect list, or null when the tile has never chosen (= all). */
function chosen(key) {
const v = cfg()[key];
return Array.isArray(v) ? v : null;
}
/* ── which teams and people to show ─────────────────────────────── */
function visibleTeams() {
const teams = Array.isArray(state?.teams) ? state.teams : [];
const wantTeams = chosen('teams');
const out = [];
for (const team of teams) {
if (wantTeams && !wantTeams.includes(team.id)) continue;
const members = team.members.filter((m) => {
if (m.status === 'declined' && !on('showDeclined')) return false;
if (m.status === 'unconfirmed' && !on('showUnconfirmed')) return false;
return true;
});
if (!members.length && !cfg().showEmptyTeams) continue;
out.push({ ...team, members });
}
return out;
}
/* ── status dot ─────────────────────────────────────────────────── */
function renderStatus() {
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
if (state.teamsError) return moduleApi.setStatus('error', `Team list: ${state.teamsError}`);
const people = (state.teams || []).reduce((n, t) => n + t.members.length, 0);
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
}
/* ── top bar ────────────────────────────────────────────────────── */
/** The counts describe what this tile shows, not what the plan holds. */
function templateValues() {
const plan = state?.plan || {};
const teams = visibleTeams();
const people = teams.reduce((n, t) => n + t.members.length, 0);
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
return {
series: plan.seriesTitle || '',
part: plan.title || '',
servicetype: state?.serviceType?.name || '',
date: plan.dates || '',
shortdate: plan.shortDates || plan.dates || '',
time: plan.serviceStartsAt
? new Date(plan.serviceStartsAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
: '',
people: people ? `${people} ${people === 1 ? 'person' : 'people'}` : '',
teams: teams.length ? `${teams.length} ${teams.length === 1 ? 'team' : 'teams'}` : '',
confirmed: confirmed ? `${confirmed} confirmed` : '',
};
}
/**
* "{serviceType} • {date} | {people} on {teams}" → up to two lines. Within
* a line • separates segments and a segment whose placeholders all came out
* empty is dropped, so a plan with no series never leaves a stray bullet.
*/
function renderTemplate(template, values) {
return String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2).map((line) => {
const kept = [];
for (const seg of line.split(/\s*[•·]\s*/)) {
let sawVar = false;
let allEmpty = true;
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
sawVar = true;
const v = values[name.toLowerCase()];
if (v) allEmpty = false;
return v || '';
}).trim();
if (sawVar && allEmpty) continue;
if (text) kept.push(text);
}
return kept.join(' • ');
});
}
function renderHead() {
const show = on('showTopBar') && state?.plan;
headEl.hidden = !show;
if (!show) return;
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
titleEl.textContent = line1 || line2 || state.serviceType?.name || 'Teams';
subEl.textContent = line1 ? line2 || '' : '';
subEl.hidden = !subEl.textContent;
}
/* ── the list ───────────────────────────────────────────────────── */
function emptyMessage() {
if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
if (!state) return { title: 'Connecting…', hint: '' };
if (!state.configured) {
return {
title: 'Planning Center not connected',
hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.serviceTypeSet) {
return {
title: 'No service type selected',
hint: 'Pick the service type to follow under PCO Plan in Admin.',
link: { href: '/admin', text: 'Open Admin' },
};
}
if (!state.reachable && !state.plan) {
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
}
if (!state.plan) {
return {
title: 'No plan found',
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans.`,
};
}
if (!(state.teams || []).length) {
return {
title: 'Nobody scheduled yet',
hint: state.teamsError || 'This plan has no team members in Planning Center.',
};
}
if (!visibleTeams().length) {
return { title: 'Nothing to show', hint: 'Every team is hidden by this tile’s settings (gear menu).' };
}
return null;
}
function buildMember(member) {
const row = document.createElement('div');
row.className = `tm-person is-${member.status}`;
if (on('showAvatars')) {
const avatar = document.createElement('div');
avatar.className = 'tm-avatar';
avatar.textContent = initialsOf(member.name);
if (member.hasPhoto) {
const img = document.createElement('img');
img.loading = 'lazy';
img.alt = '';
img.src = `/api/modules/${moduleApi.id}/team-avatar/${encodeURIComponent(member.id)}`;
// No photo, or the CDN is unreachable: the initials underneath stand.
img.addEventListener('error', () => img.remove());
avatar.appendChild(img);
}
row.appendChild(avatar);
}
const main = document.createElement('div');
main.className = 'tm-person-main';
const nameEl = document.createElement('div');
nameEl.className = 'tm-name';
nameEl.textContent = member.name;
main.appendChild(nameEl);
if (on('showPosition') && member.position && !cfg().groupByPosition) {
const pos = document.createElement('div');
pos.className = 'tm-position';
pos.textContent = member.position;
main.appendChild(pos);
}
if (cfg().showNotes && (member.notes || member.declineReason)) {
const note = document.createElement('div');
note.className = 'tm-note';
note.textContent = member.declineReason ? `Declined: ${member.declineReason}` : member.notes;
main.appendChild(note);
}
row.appendChild(main);
if (on('showStatus')) {
const status = document.createElement('div');
status.className = 'tm-status';
status.style.setProperty('--tm-status', statusColor(member.status));
status.title = STATUS_LABEL[member.status] || member.status;
row.appendChild(status);
}
return row;
}
/** One team: its header band, then its people (optionally under position sub-headings). */
function buildTeam(team, index) {
const section = document.createElement('section');
section.className = 'tm-team';
section.style.setProperty('--tm-team', teamColor(index));
if (on('showTeamHeaders')) {
const header = document.createElement('div');
header.className = 'tm-team-head';
const name = document.createElement('span');
name.className = 'tm-team-name';
name.textContent = team.name;
header.appendChild(name);
if (on('showTeamCounts')) {
const counts = document.createElement('span');
counts.className = 'tm-team-count';
const pending = team.counts.unconfirmed;
counts.textContent = pending ? `${team.members.length} · ${pending} pending` : String(team.members.length);
header.appendChild(counts);
}
section.appendChild(header);
}
const columns = String(cfg().columns || 'auto');
const grouping = Boolean(cfg().groupByPosition) && on('showPosition');
// Grouped, the container is a plain block so each position heading spans
// the width and only its own people form a grid.
const list = document.createElement('div');
list.className = grouping ? 'tm-groups' : 'tm-people';
if (!grouping && columns !== 'auto') {
list.style.setProperty('--tm-cols', columns);
list.classList.add('is-fixed-cols');
}
if (grouping) {
// Positions keep the order the members already sort in.
const groups = new Map();
for (const m of team.members) {
const key = m.position || '(no position)';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(m);
}
for (const [position, members] of groups) {
const label = document.createElement('div');
label.className = 'tm-position-head';
label.textContent = position;
list.appendChild(label);
const sub = document.createElement('div');
sub.className = 'tm-people is-sub';
if (columns !== 'auto') sub.style.setProperty('--tm-cols', columns);
sub.classList.toggle('is-fixed-cols', columns !== 'auto');
for (const m of members) sub.appendChild(buildMember(m));
list.appendChild(sub);
}
} else {
for (const m of team.members) list.appendChild(buildMember(m));
}
if (!team.members.length) {
const none = document.createElement('div');
none.className = 'tm-team-empty';
none.textContent = 'Nobody scheduled';
list.appendChild(none);
}
section.appendChild(list);
return section;
}
function renderBody() {
const empty = emptyMessage();
if (empty) {
lastKey = '';
bodyEl.innerHTML = '';
const box = document.createElement('div');
box.className = 'tm-empty';
const t = document.createElement('div');
t.className = 'tm-empty-title';
t.textContent = empty.title;
box.appendChild(t);
if (empty.hint) {
const h = document.createElement('div');
h.className = 'tm-empty-hint';
h.textContent = empty.hint;
box.appendChild(h);
}
if (empty.link) {
const a = document.createElement('a');
a.className = 'tm-empty-link';
a.href = empty.link.href;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = empty.link.text;
box.appendChild(a);
}
bodyEl.appendChild(box);
return;
}
// Rebuild only when the roster or the settings actually changed: the
// avatars are <img>s and a needless rebuild makes them flicker.
const teams = visibleTeams();
const key = JSON.stringify([teams, cfg()]);
if (key === lastKey) return;
lastKey = key;
bodyEl.innerHTML = '';
teams.forEach((team, i) => bodyEl.appendChild(buildTeam(team, i)));
}
function render() {
wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
wrap.classList.toggle('header-gradient', on('headerGradient'));
renderStatus();
renderHead();
renderBody();
}
/* ── live feed ──────────────────────────────────────────────────── */
function accept(payload) {
if (!payload || typeof payload !== 'object' || !payload.state) return;
state = payload.state;
feedOffline = false;
render();
}
function connect() {
stream = moduleApi.sse('/stream', {
open() { feedOffline = false; render(); },
error() { feedOffline = true; render(); },
events: {
state(ev) {
try {
accept(JSON.parse(ev.data));
} catch { /* a bad frame is not fatal */ }
},
},
});
}
/* ── lifecycle ──────────────────────────────────────────────────── */
return {
start() {
moduleApi.setStatus('connecting', 'Connecting…');
moduleApi.fetch('/state')
.then((res) => (res.ok ? res.json() : null))
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
render();
},
stop() {
stream?.close();
stream = null;
root.innerHTML = '';
},
onConfigChange() {
// Admin holds the connection only; the server re-inits and re-sends.
},
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "resi-cues", "id": "resi-cues",
"name": "Resi Cues", "name": "Resi Cues",
"version": "1.0.0", "version": "1.0.3",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Time until the next Resi cue point, on the board and as a timer", "description": "Time until the next Resi cue point, on the board and as a timer",
"provides": ["timers"], "provides": ["timers"],
+94 -19
View File
@@ -39,6 +39,9 @@ function msToPosition(total) {
const WINDOW_MS = 120000; // how long a reading stays useful const WINDOW_MS = 120000; // how long a reading stays useful
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session
/** /**
* Resi republishes a decoder's `position` only every few seconds, on an irregular * Resi republishes a decoder's `position` only every few seconds, on an irregular
@@ -61,6 +64,8 @@ function createPlayhead(options) {
let offsets = []; // { at, off } — off is mediaMs - wall time it was read let offsets = []; // { at, off } — off is mediaMs - wall time it was read
let spreads = []; // how far apart honest readings sit: this link's noise let spreads = []; // how far apart honest readings sit: this link's noise
let gaps = []; // how long between the playhead actually moving let gaps = []; // how long between the playhead actually moving
let changes = 0; // readings that differed from the one before
let rejected = false; // the last gap looked like a pause and was skipped
let base; // the trusted offset let base; // the trusted offset
let lastMedia; let lastMedia;
let lastNewAt; let lastNewAt;
@@ -90,15 +95,25 @@ function createPlayhead(options) {
spreads = spreads.concat(-delta).slice(-20); spreads = spreads.concat(-delta).slice(-20);
} }
} }
if (lastNewAt !== undefined) { // The first reading lands at an arbitrary point in Resi's cycle, so the gap
// from it to the next one is only a fraction of the real cadence. Learning
// from it locked in a 2s cadence that rejected every honest 30s gap.
if (lastNewAt !== undefined && changes > 0) {
const gap = t - lastNewAt; const gap = t - lastNewAt;
// The gap either side of a pause is not Resi's cadence — recording it // The gap either side of a pause is not Resi's cadence — recording it
// would inflate the estimate and make the next pause slow to notice. // would inflate the estimate and make the next pause slow to notice.
// But a pause is one gap; twice in a row is the cadence having changed.
const worst = gaps.length ? Math.max.apply(null, gaps) : 0; const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) { if (gap > 0 && gap < 60000) {
gaps = gaps.concat(gap).slice(-8); if (!worst || gap <= worst * 3 || rejected) {
gaps = gaps.concat(gap).slice(-8);
rejected = false;
} else {
rejected = true;
}
} }
} }
if (lastNewAt !== undefined) changes++;
offsets = offsets.concat({ at: t, off }); offsets = offsets.concat({ at: t, off });
lastMedia = mediaMs; lastMedia = mediaMs;
lastNewAt = t; lastNewAt = t;
@@ -118,7 +133,9 @@ function createPlayhead(options) {
// Only call it paused once the playhead has been still for clearly longer than // Only call it paused once the playhead has been still for clearly longer than
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a // Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
// countdown that freezes mid-service is the worse failure of the two. // countdown that freezes mid-service is the worse failure of the two.
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)); // Until a full gap has been measured the cadence is unknown; assume the slowest
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
const playing = base !== undefined && t - lastNewAt < pauseAfter; const playing = base !== undefined && t - lastNewAt < pauseAfter;
if (!playing) { if (!playing) {
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs }; return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
@@ -161,13 +178,43 @@ function createResiClient(options) {
return h; return h;
} }
/* A failed login used to be retried on the very next poll -- once a second, for
ever. Resi answers that with 429s, and since the retries never let up, the
account never got out of them. So a failure now holds off further attempts,
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
call in between fails at once with the same reason instead of signing in. */
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
let loginFailures = 0;
let loginBlockedUntil = 0;
let loginError = '';
async function login() { async function login() {
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, { if (now() < loginBlockedUntil) {
method: 'POST', const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, throw new Error(`${loginError} — retrying in ${wait}s`);
body: JSON.stringify({ userName: opts.username, password: opts.password }), }
}); let res;
if (!res.ok) throw new Error(`Resi login failed (${res.status})`); try {
res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ userName: opts.username, password: opts.password }),
});
} catch (err) {
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
throw new Error(loginError);
}
if (!res.ok) {
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
: `Resi login failed (${res.status})`;
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff(reason, retryAfter * 1000);
throw new Error(reason);
}
loginFailures = 0;
loginBlockedUntil = 0;
loginError = '';
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie()) const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []); || (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
const jar = {}; const jar = {};
@@ -179,24 +226,49 @@ function createResiClient(options) {
token = jar.livingas1 || ''; token = jar.livingas1 || '';
if (!token) throw new Error('Resi login returned no session cookie'); if (!token) throw new Error('Resi login returned no session cookie');
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; '); cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
loggedInAt = Date.now(); loggedInAt = now();
const me = await get('/api_v2.svc/users/me'); const me = await get('/api_v2.svc/users/me');
customerId = (me && me.customerId) || ''; customerId = (me && me.customerId) || '';
if (!customerId) throw new Error('This Resi account has no customerId'); if (!customerId) throw new Error('This Resi account has no customerId');
return me; return me;
} }
async function ensureSession() { function holdOff(reason, askedMs) {
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login(); loginFailures++;
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
loginError = reason;
} }
async function ensureSession() {
if (!token || now() - loggedInAt > 50 * 60 * 1000) await login();
}
/* The same hold-off covers every request, not just the login. A 401/403 used to
sign in again on the spot, every time: for a login that simply isn't allowed to
see a venue, that was a fresh sign-in on every poll. Now a refusal straight
after signing in is taken as what it is -- a permission -- and held off like a
failed login; only an older session is renewed. A 429 holds off too. */
async function get(path) { async function get(path) {
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() }); if (now() < loginBlockedUntil) {
if (res.status === 401 || res.status === 403) { const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
throw new Error(`${loginError} — retrying in ${wait}s`);
}
let res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) {
await login(); await login();
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() }); res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`); }
return retry.json(); if (res.status === 401 || res.status === 403) {
const where = path.includes('/venues') ? 'its venues'
: path.includes('/players') ? "this venue's decoders" : 'part of the account';
holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0);
throw new Error(loginError);
}
if (res.status === 429) {
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000);
throw new Error(loginError);
} }
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`); if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
return res.json(); return res.json();
@@ -216,7 +288,10 @@ function createResiClient(options) {
try { try {
return (await get(`${path}?_=${Date.now()}`)) || []; return (await get(`${path}?_=${Date.now()}`)) || [];
} catch (err) { } catch (err) {
if (!/\((4\d\d)\)/.test(String(err.message))) throw err; // Only an objection to the parameter itself; a refusal or a "slow down"
// (401/403/429) is about the login, and dropping it then lost fresh readings.
const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1];
if (!code || ['401', '403', '429'].includes(code)) throw err;
cacheBust = false; cacheBust = false;
} }
} }
+26
View File
@@ -197,6 +197,32 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
assert.strictEqual(surroundingCues([], 1000).next, null); assert.strictEqual(surroundingCues([], 1000).next, null);
}); });
/* ── starting partway through Resi's cycle ─────────────────────────────── */
// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a
// few seconds before a publish. The first "gap" it measured was 2s (9s after a
// reboot), and once that was the cadence every honest 30s gap was thrown out as a
// pause gap -- so playback read as paused 15s into every update, the countdown froze,
// and the next cue (and its name) stopped moving on.
for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) {
test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => {
const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 });
w.lastPublish = w.t - into;
const head = createPlayhead({ now: () => w.t });
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
let paused = 0;
for (let i = 0; i < 100; i++) if (!second().playing) paused++;
if (burst) {
w.media += 15 * 60000; // a seek, published at once...
w.lastPublish = w.t - w.nextCadence;
second(); second();
w.lastPublish = w.t - w.nextCadence; // ...and again 2s later
}
for (let i = 0; i < 300; i++) if (!second().playing) paused++;
assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`);
});
}
/* ── the matrix ────────────────────────────────────────────────────────── */ /* ── the matrix ────────────────────────────────────────────────────────── */
const argv = process.argv.slice(2).map(Number); const argv = process.argv.slice(2).map(Number);
+20 -4
View File
@@ -70,6 +70,7 @@ function emptyTimers(now) {
} }
let current = null; // the live handle, shared with routes() let current = null; // the live handle, shared with routes()
let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials
module.exports = { module.exports = {
init({ config, log }) { init({ config, log }) {
@@ -146,7 +147,9 @@ module.exports = {
elapsedMs: null, elapsedMs: null,
remainingMs, remainingMs,
targetMs: Math.max(1000, state.next.ms - from), targetMs: Math.max(1000, state.next.ms - from),
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' }, // The label already is the cue's name, and it is what the card is titled
// with; repeating it on the status line only crowded the card.
status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' },
detail: state.event || '', detail: state.event || '',
}); });
} }
@@ -271,6 +274,7 @@ module.exports = {
stateStreams, stateStreams,
timerStreams, timerStreams,
resi, resi,
credentialsKey: `${username}\n${password}`,
configured: Boolean(username && password), configured: Boolean(username && password),
stop() { stop() {
stopped = true; stopped = true;
@@ -332,9 +336,21 @@ module.exports = {
return json(res, 200, { options: [], note: 'Enter the Resi login first' }); return json(res, 200, { options: [], note: 'Enter the Resi login first' });
} }
try { try {
const client = createResiClient({ // The running module's client when it has these credentials, else one kept
username: String(config.username), password: String(config.password), // per login: a fresh client per request signed in on every admin page load,
}); // and its own hold-off never applied -- which kept Resi rate-limiting.
const username = String(config.username).trim();
const key = `${username}\n${config.password}`;
let client;
if (current && current.resi && current.credentialsKey === key) client = current.resi;
else {
if (!pickerClient || pickerClient.key !== key) {
pickerClient = { key, client: createResiClient({
username, password: String(config.password),
}) };
}
client = pickerClient.client;
}
const options = []; const options = [];
for (const v of await client.venues()) { for (const v of await client.venues()) {
for (const p of await client.players(v.uuid)) { for (const p of await client.players(v.uuid)) {
+93 -14
View File
@@ -18,8 +18,9 @@
const assert = require('assert'); const assert = require('assert');
let passed = 0; let passed = 0;
function test(name, fn) { // Awaited, so an async test finishes before the next one changes the world under it.
fn(); async function test(name, fn) {
await fn();
passed++; passed++;
console.log(` ok ${name}`); console.log(` ok ${name}`);
} }
@@ -101,7 +102,7 @@ function collect(route, req) {
const routes = server.routes({ config: {}, log: (m) => logged.push(m) }); const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
test('signs in once and finds the configured decoder', () => { await test('signs in once and finds the configured decoder', () => {
assert.strictEqual(world.logins, 1, 'should sign in exactly once'); assert.strictEqual(world.logins, 1, 'should sign in exactly once');
const state = handle.state; const state = handle.state;
assert.strictEqual(state.status, 'ok', state.message); assert.strictEqual(state.status, 'ok', state.message);
@@ -110,20 +111,20 @@ function collect(route, req) {
assert.strictEqual(state.cues.length, 3); assert.strictEqual(state.cues.length, 3);
}); });
test('works out the cue either side of the playhead', () => { await test('works out the cue either side of the playhead', () => {
const state = handle.state; const state = handle.state;
assert.strictEqual(state.next.name, 'Campus Host'); assert.strictEqual(state.next.name, 'Campus Host');
assert.strictEqual(state.prev.name, 'Welcome'); assert.strictEqual(state.prev.name, 'Welcome');
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`); assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
}); });
test('/state answers the same snapshot as the stream', () => { await test('/state answers the same snapshot as the stream', () => {
const res = collect(routes['GET /state']); const res = collect(routes['GET /state']);
assert.strictEqual(res.code, 200); assert.strictEqual(res.code, 200);
assert.deepStrictEqual(JSON.parse(res.body), handle.state); assert.deepStrictEqual(JSON.parse(res.body), handle.state);
}); });
test('/stream opens with an event so a new tile paints at once', () => { await test('/stream opens with an event so a new tile paints at once', () => {
const res = collect(routes['GET /stream']); const res = collect(routes['GET /stream']);
assert.strictEqual(res.code, 200); assert.strictEqual(res.code, 200);
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8'); assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
@@ -131,7 +132,7 @@ function collect(route, req) {
assert.ok(res.body.includes('event: state'), 'should send an initial state event'); assert.ok(res.body.includes('event: state'), 'should send an initial state event');
}); });
test('/timers offers the countdown and the playhead, shaped as the guide says', () => { await test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
const snap = JSON.parse(collect(routes['GET /timers']).body); const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.source, 'resi-cues'); assert.strictEqual(snap.source, 'resi-cues');
assert.strictEqual(snap.label, 'Resi'); assert.strictEqual(snap.label, 'Resi');
@@ -145,7 +146,7 @@ function collect(route, req) {
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`); assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
// the countdown runs from the cue before it, so a consumer can colour progress // the countdown runs from the cue before it, so a consumer can colour progress
assert.strictEqual(next.targetMs, 450000); assert.strictEqual(next.targetMs, 450000);
assert.strictEqual(next.status.text, 'Campus Host'); assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past'); assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
assert.strictEqual(playhead.id, 'playhead'); assert.strictEqual(playhead.id, 'playhead');
@@ -154,7 +155,7 @@ function collect(route, req) {
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000); assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
}); });
test('a cue fired at the host moves the countdown on the next poll', async () => { await test('a cue fired at the host moves the countdown on the next poll', async () => {
world.position = '00:12:40.000'; // jumped past Campus Host world.position = '00:12:40.000'; // jumped past Campus Host
tickFn(); tickFn();
await settle(); await settle();
@@ -162,10 +163,10 @@ function collect(route, req) {
assert.strictEqual(state.next.name, 'Message'); assert.strictEqual(state.next.name, 'Message');
assert.strictEqual(state.prev.name, 'Campus Host'); assert.strictEqual(state.prev.name, 'Campus Host');
const snap = JSON.parse(collect(routes['GET /timers']).body); const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.timers[0].label, 'Message'); assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue
}); });
test('a decoder with no event reports nothing playing rather than stale cues', async () => { await test('a decoder with no event reports nothing playing rather than stale cues', async () => {
world.eventId = ''; world.eventId = '';
tickFn(); tickFn();
await settle(); await settle();
@@ -174,13 +175,13 @@ function collect(route, req) {
assert.strictEqual(handle.state.event, ''); assert.strictEqual(handle.state.event, '');
}); });
test('health speaks about the upstream, not about itself', () => { await test('health speaks about the upstream, not about itself', () => {
const h = handle.health(); const h = handle.health();
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status); assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
assert.ok(h.message.length > 0, 'health should say something useful'); assert.ok(h.message.length > 0, 'health should say something useful');
}); });
test('an unconfigured module idles instead of hammering Resi', () => { await test('an unconfigured module idles instead of hammering Resi', () => {
const before = world.logins; const before = world.logins;
const idle = server.init({ config: {}, log: () => {} }); const idle = server.init({ config: {}, log: () => {} });
assert.strictEqual(world.logins, before, 'must not sign in with no credentials'); assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
@@ -189,12 +190,90 @@ function collect(route, req) {
idle.stop(); idle.stop();
}); });
test('stop() leaves nothing running', () => { await test('stop() leaves nothing running', () => {
handle.stop(); handle.stop();
const res = collect(routes['GET /state']); const res = collect(routes['GET /state']);
assert.strictEqual(JSON.parse(res.body).status, 'error'); assert.strictEqual(JSON.parse(res.body).status, 'error');
}); });
// Seen live at North Attleboro: a failed login was retried on every 1s poll, so
// Resi answered 429 and kept answering it, and the decoder picker stayed empty.
await test('a failed login holds off instead of retrying every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let limited = true;
const client = createResiClient({
username: 'a@b.c',
password: 'secret',
now: () => t,
fetch: async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
logins++;
if (limited) return reply({}, { ok: false, status: 429 });
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]);
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /rate-limiting/);
for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls
assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`);
assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden');
limited = false;
t += 5 * 60000; // past the longest hold-off
assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in');
const after = logins;
for (let i = 0; i < 30; i++) { t += 1000; await poll(); }
assert.strictEqual(logins, after, 'a good session is not signed in again');
});
await test('a rejected password says so', async () => {
const { createResiClient } = require('./resi');
const client = createResiClient({
username: 'a@b.c', password: 'wrong', now: () => 0,
fetch: async () => reply({}, { ok: false, status: 400 }),
});
await assert.rejects(client.venues(), /check the password/);
});
// Seen live: media@waters.church signs in fine but may not list venues (403). The
// client answered every 403 by signing in again -- a fresh login on every poll.
await test('a login that may not see venues is held off, not signed in every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let venueCalls = 0;
const client = createResiClient({
username: 'a@b.c', password: 'secret', now: () => t,
fetch: async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
logins++;
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); }
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /isn't allowed to see its venues \(403\)/);
for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls
// At most one renewal once the session is over a minute old (in case it really
// had expired), then the hold-off keeps growing -- not one sign-in per poll.
assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`);
assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`);
assert.match(await poll(), /ask a Resi admin/);
});
globalThis.setInterval = realSetInterval; globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval; globalThis.clearInterval = realClearInterval;
console.log(`\n${passed} tests passed`); console.log(`\n${passed} tests passed`);
-134
View File
@@ -11,8 +11,6 @@
* GET /services/v2/service_types every service type, with its * GET /services/v2/service_types every service type, with its
* parent folder relationship * parent folder relationship
* GET /services/v2/service_types/{id} * GET /services/v2/service_types/{id}
* GET /services/v2/service_types/{id}/teams every team, including one
* nobody is scheduled on
* GET /services/v2/service_types/{id}/item_note_categories * GET /services/v2/service_types/{id}/item_note_categories
* the note categories the * the note categories the
* items below use, plus one * items below use, plus one
@@ -27,15 +25,6 @@
* "Starts in" state is visible * "Starts in" state is visible
* GET …/plans/{id}/items?include=… items + Song / Arrangement / * GET …/plans/{id}/items?include=… items + Song / Arrangement /
* ItemNote compound documents * ItemNote compound documents
* GET …/plans/{id}/team_members?include=… who is scheduled: PlanPerson
* records + Team / Person, with
* confirmed / unconfirmed /
* declined statuses, a team
* nobody is on, and people with
* and without a photo
* GET /avatars/{personId}.svg those photos (initials on a
* disc) — the module proxies
* them like the real CDN
* *
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's * Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
* playlist, so running both mocks exercises the ProPresenter matching. * playlist, so running both mocks exercises the ProPresenter matching.
@@ -205,107 +194,6 @@ function itemsDocument(planId, include) {
/* ── JSON:API plumbing ──────────────────────────────────────────────── */ /* ── JSON:API plumbing ──────────────────────────────────────────────── */
/**
* Who is scheduled, as Planning Center returns it from
* /plans/{id}/team_members?include=team,person: teams in their own order,
* people with a position and a scheduling status ("C" confirmed,
* "U" unconfirmed, "D" declined). `photo` false is somebody Planning Center
* has no picture for — the tile draws their initials instead.
*/
const TEAMS = [
{
id: 't1', name: 'Worship', sequence: 1, members: [
{ id: 'tp1', person: 'p1', name: 'Sarah Miles', position: 'Worship Leader', status: 'C', photo: true },
{ id: 'tp2', person: 'p2', name: 'Dan Whitfield', position: 'Acoustic Guitar', status: 'C', photo: true },
{ id: 'tp3', person: 'p3', name: 'Priya Raman', position: 'Keys', status: 'U', photo: true },
{ id: 'tp4', person: 'p4', name: 'Marcus Webb', position: 'Drums', status: 'C', photo: false },
{ id: 'tp5', person: 'p5', name: 'Elena Fischer', position: 'Bass', status: 'D', photo: true, decline: 'Out of town' },
{ id: 'tp6', person: 'p6', name: 'Joy Adeyemi', position: 'Vocals', status: 'C', photo: true },
{ id: 'tp7', person: 'p7', name: 'Tom Brennan', position: 'Vocals', status: 'U', photo: false },
],
},
{
id: 't2', name: 'Production', sequence: 2, members: [
{ id: 'tp8', person: 'p8', name: 'Chris Nolan', position: 'FOH Audio', status: 'C', photo: true, notes: 'Arrives 7:15 for line check' },
{ id: 'tp9', person: 'p9', name: 'Amara Okafor', position: 'ProPresenter', status: 'C', photo: true },
{ id: 'tp10', person: 'p10', name: 'Nate Sørensen', position: 'Lighting', status: 'U', photo: false },
{ id: 'tp11', person: 'p11', name: 'Bethany Cruz', position: 'Camera 1', status: 'C', photo: true },
{ id: 'tp12', person: 'p12', name: 'Owen Hart', position: 'Camera 2', status: 'C', photo: false },
],
},
{
id: 't3', name: 'Hosting', sequence: 3, members: [
{ id: 'tp13', person: 'p13', name: 'Pastor Mike Alvarez', position: 'MC', status: 'C', photo: true },
{ id: 'tp14', person: 'p14', name: 'Grace Lin', position: 'Greeter', status: 'C', photo: true },
{ id: 'tp15', person: 'p15', name: 'Sam Okonkwo', position: 'Greeter', status: 'U', photo: false },
],
},
// A team with nobody on it — a tile hides it unless "Teams with nobody" is on.
{ id: 't4', name: 'Kids', sequence: 4, members: [] },
];
/** A stand-in avatar: initials on a coloured disc, as an SVG the module can proxy. */
function avatarSvg(name) {
const words = String(name).trim().split(/\s+/);
const initials = (words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0].slice(0, 2)).toUpperCase();
let hash = 0;
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) % 360;
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">`
+ `<circle cx="48" cy="48" r="48" fill="hsl(${hash} 55% 42%)"/>`
+ `<text x="48" y="48" fill="#fff" font-family="system-ui,sans-serif" font-size="38" font-weight="700"`
+ ` text-anchor="middle" dominant-baseline="central">${initials}</text></svg>`;
}
function teamMembersDocument(planId, include, port) {
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
const data = [];
const included = [];
const seenTeams = new Set();
for (const team of TEAMS) {
for (const m of team.members) {
data.push({
type: 'PlanPerson',
id: m.id,
attributes: {
name: m.name,
status: m.status,
team_position_name: m.position,
notes: m.notes || null,
decline_reason: m.decline || null,
photo_thumbnail: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
status_updated_at: iso(bootedAt - 3600000),
},
relationships: {
plan: { data: { type: 'Plan', id: planId } },
team: { data: { type: 'Team', id: team.id } },
person: { data: { type: 'Person', id: m.person } },
},
});
if (wants.has('person')) {
included.push({
type: 'Person',
id: m.person,
attributes: {
name: m.name,
first_name: m.name.split(' ')[0],
last_name: m.name.split(' ').slice(-1)[0],
avatar: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
},
});
}
if (wants.has('team') && !seenTeams.has(team.id)) {
seenTeams.add(team.id);
included.push({
type: 'Team',
id: team.id,
attributes: { name: team.name, sequence: team.sequence, schedule_to: 'plan' },
});
}
}
}
return { data, included };
}
function folderRecord(f) { function folderRecord(f) {
return { return {
type: 'Folder', type: 'Folder',
@@ -354,16 +242,6 @@ const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`); const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`); console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`);
// Photos are plain images on Planning Center's CDN — no auth there, none here.
const avatar = url.pathname.match(/^\/avatars\/([A-Za-z0-9_-]+)\.svg$/);
if (avatar) {
const person = [].concat(...TEAMS.map((t) => t.members)).find((m) => m.person === avatar[1]);
if (!person) return notFound(res);
const body = avatarSvg(person.name);
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'public, max-age=3600' });
return void res.end(body);
}
if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) { if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) {
return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] }); return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] });
} }
@@ -388,13 +266,6 @@ const server = http.createServer((req, res) => {
const st = SERVICE_TYPES.find((x) => x.id === parts[1]); const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
if (!st) return notFound(res); if (!st) return notFound(res);
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) }); if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
if (parts[2] === 'teams' && parts.length === 3) {
return collection(res, TEAMS.map((t) => ({
type: 'Team', id: t.id,
attributes: { name: t.name, sequence: t.sequence, schedule_to: 'plan' },
relationships: { service_type: { data: { type: 'ServiceType', id: st.id } } },
})), url);
}
if (parts[2] === 'item_note_categories' && parts.length === 3) { if (parts[2] === 'item_note_categories' && parts.length === 3) {
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url); return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
} }
@@ -415,11 +286,6 @@ const server = http.createServer((req, res) => {
if (!plan) return notFound(res); if (!plan) return notFound(res);
if (parts.length === 4) return send(res, 200, { data: planRecord(plan) }); if (parts.length === 4) return send(res, 200, { data: planRecord(plan) });
if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url); if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url);
if (parts[4] === 'team_members') {
const doc = teamMembersDocument(plan.id, url.searchParams.get('include'), PORT);
doc.links = { self: url.pathname + url.search };
return send(res, 200, doc);
}
if (parts[4] === 'items') { if (parts[4] === 'items') {
const doc = itemsDocument(plan.id, url.searchParams.get('include')); const doc = itemsDocument(plan.id, url.searchParams.get('include'));
doc.links = { self: url.pathname + url.search }; doc.links = { self: url.pathname + url.search };