Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79d3237328 | ||
|
|
67f612e371 | ||
|
|
0f0adb4c60 |
@@ -323,10 +323,7 @@ below. Nothing is installed without someone clicking.
|
||||
session (Start ProdDash.command) and approve the one-time prompt; a server
|
||||
started over SSH or by bare launchd silently captures zeros. (Engine
|
||||
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
||||
- **PCO Plan** — Planning Center Services, as two tiles in the + picker:
|
||||
**Plan** (the order of service) and **Teams** (who is scheduled).
|
||||
|
||||
**Plan** shows every
|
||||
- **PCO Plan** — the order of service from Planning Center Services: every
|
||||
plan item with its scheduled start time, length, type, song key,
|
||||
description and item notes. When the ProPresenter module is running it
|
||||
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
|
||||
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||
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
|
||||
PIN-protected sending as the location. The + picker lists one transcript
|
||||
tile per configured channel, titled `#channel`, and two send surfaces (one
|
||||
|
||||
+1
-154
@@ -1,9 +1,5 @@
|
||||
/* 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
|
||||
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 →
|
||||
@@ -12,8 +8,6 @@
|
||||
(saved with the tile, 14 px until someone presses one); /admin only holds
|
||||
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>';
|
||||
|
||||
/* 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>';
|
||||
|
||||
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 skew = 0; // server clock − local clock, ms
|
||||
let feedOffline = false;
|
||||
@@ -34,7 +22,6 @@ function createPlan({ root, moduleApi }) {
|
||||
let ticker = null;
|
||||
let lastCurrentId = null;
|
||||
let lastListKey = '';
|
||||
let absView = null; // what the server says AbleSet is doing, if anything
|
||||
let userScrolledAt = 0;
|
||||
|
||||
root.innerHTML = `
|
||||
@@ -494,133 +481,6 @@ function createPlan({ root, moduleApi }) {
|
||||
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. */
|
||||
function renderLiveMarks() {
|
||||
const live = state?.live || {};
|
||||
@@ -659,12 +519,11 @@ function createPlan({ root, moduleApi }) {
|
||||
}
|
||||
if (!h.endedAt) elapsedEl.classList.add('is-running');
|
||||
}
|
||||
renderAbleSet();
|
||||
if (currentId !== lastCurrentId) {
|
||||
lastCurrentId = currentId;
|
||||
if (currentId && tilePrefs().autoScroll !== false && Date.now() - userScrolledAt > 8000) {
|
||||
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() {
|
||||
renderAbleSet();
|
||||
if (!state?.plan) return;
|
||||
renderClock();
|
||||
if (state.live?.following && on('showItemRuntime')) renderLiveMarks();
|
||||
@@ -751,7 +609,6 @@ function createPlan({ root, moduleApi }) {
|
||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
||||
state = payload.state;
|
||||
if (Number.isFinite(payload.now)) skew = payload.now - Date.now();
|
||||
if (payload.ableset) absView = payload.ableset;
|
||||
feedOffline = false;
|
||||
render();
|
||||
}
|
||||
@@ -772,16 +629,6 @@ function createPlan({ root, moduleApi }) {
|
||||
accept(JSON.parse(ev.data));
|
||||
} 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();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"id": "pco-plan",
|
||||
"name": "PCO Plan",
|
||||
"version": "1.8.0",
|
||||
"version": "1.6.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": [
|
||||
"timers"
|
||||
],
|
||||
@@ -382,245 +382,6 @@
|
||||
"label": "Match line",
|
||||
"default": true,
|
||||
"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
@@ -9,10 +9,6 @@
|
||||
*
|
||||
* GET /state → { state, now } (instant paint for new tiles)
|
||||
* 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
|
||||
* GET /service-types → { options } for the admin "Service type" select,
|
||||
* grouped by Planning Center folder path
|
||||
@@ -21,8 +17,6 @@
|
||||
* (documents ProPresenter has shown or lists — see below)
|
||||
* GET /note-categories → { options } for a tile's "Item notes" categories:
|
||||
* 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/stream → SSE, `timers` events timers to the Timers module")
|
||||
*
|
||||
@@ -35,14 +29,6 @@
|
||||
* GET …/plans/{id}/plan_times (service start times)
|
||||
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
||||
* 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,
|
||||
* 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 os = require('os');
|
||||
const path = require('path');
|
||||
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
|
||||
const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
|
||||
const {
|
||||
MINUTE,
|
||||
serviceTimes,
|
||||
@@ -96,28 +82,9 @@ const HEARTBEAT_MS = 15000;
|
||||
/** The Now/Next module whose feed we follow. */
|
||||
const NOW_NEXT_ID = 'propresenter-now-next';
|
||||
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;
|
||||
/** How many distinct documents seen live are remembered for the admin selects. */
|
||||
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. */
|
||||
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
|
||||
/**
|
||||
@@ -145,8 +112,6 @@ function clearedState() {
|
||||
serviceType: { id: '', name: '' },
|
||||
plan: null,
|
||||
items: [],
|
||||
teams: [], // who is scheduled, by team (the Teams tile)
|
||||
teamsError: '', // why the last team read failed, if it did
|
||||
live: clearedLive(),
|
||||
service: clearedService(),
|
||||
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. */
|
||||
function clearedService() {
|
||||
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. */
|
||||
function serviceLabel(win) {
|
||||
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() {
|
||||
if (!pco.configured || !serviceTypeId) return;
|
||||
let planRec = null;
|
||||
@@ -884,8 +573,6 @@ module.exports = {
|
||||
if (state.plan) log('no plan found any more — service timing reset');
|
||||
state.plan = null;
|
||||
state.items = [];
|
||||
state.teams = [];
|
||||
state.teamsError = "";
|
||||
state.reachable = true;
|
||||
state.lastError = '';
|
||||
state.lastFetched = Date.now();
|
||||
@@ -902,8 +589,6 @@ module.exports = {
|
||||
state.plan = plan;
|
||||
state.items = 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.lastError = '';
|
||||
state.lastFetched = Date.now();
|
||||
@@ -1297,170 +982,6 @@ module.exports = {
|
||||
const badPorts = new Set();
|
||||
|
||||
/** 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) {
|
||||
if (shellPort) return; // the shell told us; nothing to learn
|
||||
const port = portFromHost(hostHeader);
|
||||
@@ -1472,10 +993,6 @@ module.exports = {
|
||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||
nnRequest = null;
|
||||
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';
|
||||
connectNowNext();
|
||||
}
|
||||
// Always: whether there is an AbleSet module to follow is its answer, not
|
||||
// an admin setting here.
|
||||
connectAbleSet();
|
||||
mirrorService();
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
@@ -1769,7 +1283,6 @@ module.exports = {
|
||||
timerStreams,
|
||||
pco,
|
||||
getState: () => state,
|
||||
getAbleSet: () => abs,
|
||||
learnPort,
|
||||
refresh() {
|
||||
clearTimeout(pollTimer);
|
||||
@@ -1780,8 +1293,6 @@ module.exports = {
|
||||
timersSnapshot,
|
||||
documentOptions,
|
||||
noteCategoryOptions,
|
||||
teamOptions,
|
||||
avatarUrlFor: (id) => teamPhotos.get(String(id)) || "",
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1791,10 +1302,7 @@ module.exports = {
|
||||
clearInterval(ticker);
|
||||
clearTimeout(pollTimer);
|
||||
clearTimeout(nnRetryTimer);
|
||||
clearTimeout(absRetryTimer);
|
||||
clearTimeout(absPosTimer);
|
||||
try { nnRequest?.destroy(); } catch { /* gone */ }
|
||||
try { absRequest?.destroy(); } catch { /* gone */ }
|
||||
for (const set of [streams, timerStreams]) {
|
||||
for (const res of set) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
@@ -1836,11 +1344,7 @@ module.exports = {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
current?.learnPort(req.headers.host);
|
||||
json(res, 200, {
|
||||
state: current ? current.getState() : clearedState(),
|
||||
ableset: current ? current.getAbleSet() : clearedAbleSet(),
|
||||
now: Date.now(),
|
||||
});
|
||||
json(res, 200, { state: current ? current.getState() : clearedState(), now: Date.now() });
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
@@ -1848,7 +1352,6 @@ module.exports = {
|
||||
sseHead(res);
|
||||
const state = current ? current.getState() : clearedState();
|
||||
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();
|
||||
const { streams } = current;
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
@@ -1882,28 +1385,6 @@ module.exports = {
|
||||
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. */
|
||||
'GET /documents': (req, res) => {
|
||||
if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' });
|
||||
|
||||
@@ -86,7 +86,6 @@
|
||||
}
|
||||
|
||||
[data-module="pco-plan"] .pp-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
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-down { color: 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%));
|
||||
}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"id": "resi-cues",
|
||||
"name": "Resi Cues",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Time until the next Resi cue point, on the board and as a timer",
|
||||
"provides": ["timers"],
|
||||
|
||||
+94
-19
@@ -39,6 +39,9 @@ function msToPosition(total) {
|
||||
|
||||
const WINDOW_MS = 120000; // how long a reading stays useful
|
||||
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
|
||||
@@ -61,6 +64,8 @@ function createPlayhead(options) {
|
||||
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 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 lastMedia;
|
||||
let lastNewAt;
|
||||
@@ -90,15 +95,25 @@ function createPlayhead(options) {
|
||||
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;
|
||||
// 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.
|
||||
// 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;
|
||||
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
|
||||
gaps = gaps.concat(gap).slice(-8);
|
||||
if (gap > 0 && gap < 60000) {
|
||||
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 });
|
||||
lastMedia = mediaMs;
|
||||
lastNewAt = t;
|
||||
@@ -118,7 +133,9 @@ function createPlayhead(options) {
|
||||
// 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
|
||||
// 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;
|
||||
if (!playing) {
|
||||
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
||||
@@ -161,13 +178,43 @@ function createResiClient(options) {
|
||||
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() {
|
||||
const 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 }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
|
||||
if (now() < loginBlockedUntil) {
|
||||
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
||||
throw new Error(`${loginError} — retrying in ${wait}s`);
|
||||
}
|
||||
let res;
|
||||
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())
|
||||
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
||||
const jar = {};
|
||||
@@ -179,24 +226,49 @@ function createResiClient(options) {
|
||||
token = jar.livingas1 || '';
|
||||
if (!token) throw new Error('Resi login returned no session cookie');
|
||||
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
||||
loggedInAt = Date.now();
|
||||
loggedInAt = now();
|
||||
const me = await get('/api_v2.svc/users/me');
|
||||
customerId = (me && me.customerId) || '';
|
||||
if (!customerId) throw new Error('This Resi account has no customerId');
|
||||
return me;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
|
||||
function holdOff(reason, askedMs) {
|
||||
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) {
|
||||
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (now() < loginBlockedUntil) {
|
||||
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();
|
||||
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
|
||||
return retry.json();
|
||||
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
}
|
||||
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})`);
|
||||
return res.json();
|
||||
@@ -216,7 +288,10 @@ function createResiClient(options) {
|
||||
try {
|
||||
return (await get(`${path}?_=${Date.now()}`)) || [];
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,32 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
|
||||
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 ────────────────────────────────────────────────────────── */
|
||||
|
||||
const argv = process.argv.slice(2).map(Number);
|
||||
|
||||
@@ -70,6 +70,7 @@ function emptyTimers(now) {
|
||||
}
|
||||
|
||||
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 = {
|
||||
init({ config, log }) {
|
||||
@@ -146,7 +147,9 @@ module.exports = {
|
||||
elapsedMs: null,
|
||||
remainingMs,
|
||||
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 || '',
|
||||
});
|
||||
}
|
||||
@@ -271,6 +274,7 @@ module.exports = {
|
||||
stateStreams,
|
||||
timerStreams,
|
||||
resi,
|
||||
credentialsKey: `${username}\n${password}`,
|
||||
configured: Boolean(username && password),
|
||||
stop() {
|
||||
stopped = true;
|
||||
@@ -332,9 +336,21 @@ module.exports = {
|
||||
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
|
||||
}
|
||||
try {
|
||||
const client = createResiClient({
|
||||
username: String(config.username), password: String(config.password),
|
||||
});
|
||||
// The running module's client when it has these credentials, else one kept
|
||||
// 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 = [];
|
||||
for (const v of await client.venues()) {
|
||||
for (const p of await client.players(v.uuid)) {
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
fn();
|
||||
// Awaited, so an async test finishes before the next one changes the world under it.
|
||||
async function test(name, fn) {
|
||||
await fn();
|
||||
passed++;
|
||||
console.log(` ok ${name}`);
|
||||
}
|
||||
@@ -101,7 +102,7 @@ function collect(route, req) {
|
||||
|
||||
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');
|
||||
const state = handle.state;
|
||||
assert.strictEqual(state.status, 'ok', state.message);
|
||||
@@ -110,20 +111,20 @@ function collect(route, req) {
|
||||
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;
|
||||
assert.strictEqual(state.next.name, 'Campus Host');
|
||||
assert.strictEqual(state.prev.name, 'Welcome');
|
||||
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']);
|
||||
assert.strictEqual(res.code, 200);
|
||||
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']);
|
||||
assert.strictEqual(res.code, 200);
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
assert.strictEqual(snap.source, 'resi-cues');
|
||||
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}`);
|
||||
// the countdown runs from the cue before it, so a consumer can colour progress
|
||||
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.strictEqual(playhead.id, 'playhead');
|
||||
@@ -154,7 +155,7 @@ function collect(route, req) {
|
||||
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
|
||||
tickFn();
|
||||
await settle();
|
||||
@@ -162,10 +163,10 @@ function collect(route, req) {
|
||||
assert.strictEqual(state.next.name, 'Message');
|
||||
assert.strictEqual(state.prev.name, 'Campus Host');
|
||||
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 = '';
|
||||
tickFn();
|
||||
await settle();
|
||||
@@ -174,13 +175,13 @@ function collect(route, req) {
|
||||
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();
|
||||
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
||||
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 idle = server.init({ config: {}, log: () => {} });
|
||||
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
||||
@@ -189,12 +190,90 @@ function collect(route, req) {
|
||||
idle.stop();
|
||||
});
|
||||
|
||||
test('stop() leaves nothing running', () => {
|
||||
await test('stop() leaves nothing running', () => {
|
||||
handle.stop();
|
||||
const res = collect(routes['GET /state']);
|
||||
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.clearInterval = realClearInterval;
|
||||
console.log(`\n${passed} tests passed`);
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
* GET /services/v2/service_types every service type, with its
|
||||
* parent folder relationship
|
||||
* 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
|
||||
* the note categories the
|
||||
* items below use, plus one
|
||||
@@ -27,15 +25,6 @@
|
||||
* "Starts in" state is visible
|
||||
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
|
||||
* 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
|
||||
* playlist, so running both mocks exercises the ProPresenter matching.
|
||||
@@ -205,107 +194,6 @@ function itemsDocument(planId, include) {
|
||||
|
||||
/* ── 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) {
|
||||
return {
|
||||
type: 'Folder',
|
||||
@@ -354,16 +242,6 @@ const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
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 || ''))) {
|
||||
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]);
|
||||
if (!st) return notFound(res);
|
||||
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) {
|
||||
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 (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] === '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') {
|
||||
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
|
||||
doc.links = { self: url.pathname + url.search };
|
||||
|
||||
Reference in New Issue
Block a user