Compare commits

..
Author SHA1 Message Date
michael 1410cbc169 Merge pull request 'Shell 1.13.0: the Layouts menu lists the tiles a layout holds but the board is not showing, with a ✕ to remove each' (#46) from claude/layouts-menu-not-shown into main 2026-10-11 09:54:06 -04:00
christian 60f82690f3 Merge pull request 'Resi Cues 1.0.3: the next cue's name keeps moving, and Resi logins stop hammering the account' (#43) from claude/resi-cues-startup-pause into main 2026-10-07 17:24:56 -04:00
Waters ChurchandClaude Fable 5.1 7481d1d9e2 Shell 1.13.0: the Layouts menu lists the tiles a layout holds but the board is not showing, with a ✕ to remove each
A tile whose module stopped offering its picker entry — Slack's Send message
and Quick replies since the Send Message module took them over — kept its
place in the layout and drew nothing, as designed, but still held its grid
cells with no control anywhere to let them go: a dead area the operator
could not place anything in.

The Layouts menu now has a Not shown section, present only while the layout
holds such a tile. One row per tile: its title (or the module's name plus
the variant, or the module id when it is not installed), why in a few words
— "not offered right now", "module disabled", "module needs a newer
ProdDash", "module not installed" — and a drawn ✕ with a tooltip that
removes it, saves the layout and frees the cells, leaving the menu open as
the Grid Size stepper and Alerts do. The section repaints while the menu is
open whenever tiles become offered or stop being, and only when the list
changed. A tile that merely waits for its module keeps its place as before.

GET /api/modules also returns `inactive` — the installed modules that are
not running, id, name and reason only — so a dashboard can tell a disabled
module from one that is not installed. The toast after loading a named
layout now says the Layouts menu can remove the tiles it did not show.
README and the module guide updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 11:46:50 -04:00
christianandClaude Opus 5.5 79d3237328 Resi Cues 1.0.3: a login without access to the venue stops signing in every poll
Every 401/403 was answered by signing in again on the spot. For a login that
works but may not list the account's venues (media@waters.church at North
Attleboro got 403 on /venues), that was a successful sign-in on every 1s poll
-- the pattern that had just got another Resi account locked.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:44:14 -04:00
michael c9e03398a6 Merge pull request 'PCO Plan 1.10.0: follow several service types in priority order, and read any of them from any dashboard' (#42) from claude/pco-multi-service-types into main 2026-10-06 15:01:09 -04:00
christianandClaude Opus 5.5 0f0adb4c60 Resi Cues 1.0.1: the next cue's name keeps moving on a 30-second Resi
On a Resi that publishes every ~30s, the board could start a few seconds
before a publish and learn a 2s cadence from that partial first gap. Every
honest 30s gap after that was thrown out as a pause, so playback read as
paused 15s into every update: the countdown froze, and the next cue -- and
the name on the Timers card -- stopped moving on.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:59:12 -04:00
Waters ChurchandClaude Opus 5 7bb1131378 PCO Plan 1.10.0: follow several service types in priority order, and read any of them from any dashboard
An account runs more than one service type — a Sunday morning, a Wednesday
night, a youth service, a second campus — and until now that meant one
ProdDash per type. The module now follows as many as are configured.

Admin picks them in priority order. Shell 1.12.0 gives `multiselect` an
`orderable: true` that stops being a ticklist: the picks sit in a numbered
list with a nudge either way and a remove, over a dropdown holding what is
left. Forty switches to read past is not a picker, and a ticklist cannot
carry an order anyway. The order is the setting — when two types have a plan
at the same date and time, the one higher up wins.

The server gathers candidate plans from every ticked type and tiers them:
today's plan first, then the next upcoming, then the most recent past, with
the configured order breaking a tie. A type that fails is skipped rather
than failing the read, unless it is an auth or rate-limit answer, which
belongs to the whole connection. The types not being followed are read on a
slower lane (45 s) into `state.others`, so they are there to look at without
a second connection.

Every tile's title bar carries a service-type control. Picking one moves
every PCO Plan tile on that browser's dashboard — plan, teams and song key
together — onto its plan, over a small page bus in `viewed-type.js`; the
choice is per browser and survives a reload. The control hides itself when
only one type is configured.

Live machinery stays with the type being followed: another type's plan shows
its running order with no now marker, no AbleSet and no service clock — it
shows that plan's own scheduled time instead, since borrowing the running
clock of a different service is exactly the misreading the amber top-bar
stripe exists to prevent.

Old configs carry `serviceTypeId`; it is read once into the new array and
saved, and plan pins still resolve bare, so nothing has to be re-entered.

`tools/pco-mock.js` takes `--types N` to pad the list out, for trying the
picker against an account with dozens of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 14:32:19 -04:00
michael f9cd292693 Merge pull request 'Shell 1.11.0: alerts — a module flashes every dashboard listening on one of six channels' (#41) from claude/proddash-alerts-feature-67694f into main 2026-10-05 17:06:54 -04:00
21 changed files with 1180 additions and 181 deletions
+26 -8
View File
@@ -316,10 +316,15 @@ below. Nothing is installed without someone clicking.
name, load a named layout (it becomes a local copy — browsers are never name, load a named layout (it becomes a local copy — browsers are never
linked), or reset this browser's dashboard. Loading says what it did — linked), or reset this browser's dashboard. Loading says what it did —
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right *Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
now* — with **Undo** in the message to get back the arrangement you had. now; the Layouts menu can remove them* — with **Undo** in the message to
A tile whose module or picker entry isn't offered at the moment keeps its get back the arrangement you had. A tile whose module or picker entry
place and shows nothing, then appears when it is; it is never replaced by isn't offered at the moment keeps its place and shows nothing, then
the module's default tile. Saving over an existing name asks first, and appears when it is; it is never replaced by the module's default tile.
While any tile is in that state the same menu has a **Not shown** section
listing each one with why — *not offered right now*, *module disabled*,
*module not installed* — and a ✕ that removes it and frees its cells, for
the tile that is not coming back (a module that stopped offering that
kind of tile). Saving over an existing name asks first, and
the server keeps the copy it replaced: **Restore previous** in Admin → the server keeps the copy it replaced: **Restore previous** in Admin →
Saved layouts swaps it back (and swapping again undoes that). The menu's Saved layouts swaps it back (and swapping again undoes that). The menu's
**Grid Size** stepper sets how many columns this browser cuts the board into — **Grid Size** stepper sets how many columns this browser cuts the board into —
@@ -403,10 +408,23 @@ below. Nothing is installed without someone clicking.
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
message and lands on the plan's message item (an item titled Message, message and lands on the plan's message item (an item titled Message,
Sermon or Teaching, else the first item under such a header). Admin Sermon or Teaching, else the first item under such a header). Admin
config is the connection: Planning config is the connection: Planning Center credentials (Personal Access
Center credentials (Personal Access Token app ID + secret, or an OAuth Token app ID + secret, or an OAuth token), the service types to follow (a
token), the service type (a list grouped by your Planning Center folders) list grouped by your Planning Center folders) and an optional pinned plan.
and an optional pinned plan. Everything about the display is per tile **Tick as many service types as you like** — the module follows whichever
of them has a plan soonest, so a campus running Sunday mornings, a
Wednesday night and a youth service needs one dashboard, not three. The
ticked types are **numbered in priority order**, nudged up and down with
the arrows beside them, and that order decides the winner when two of them
have a plan at the same date and time. Every tile's title bar then carries
a **service-type control** for reading one of the others: pick a type and
every PCO Plan tile on *that* browser's dashboard — plan, teams and song
key together — moves to its plan, marked with an amber stripe, while the
machine goes on following the live one. The choice is that browser's
alone, it sticks until changed, and the control hides itself when only one
service type is ticked. Live machinery belongs to the followed type:
another type's plan shows its running order without the now marker, the
service clock or AbleSet. Everything about the display is per tile
(gear menu): the top-bar text (placeholders such as (gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a `{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail. show/hide checklist for every detail.
+24 -4
View File
@@ -222,7 +222,9 @@ Both schemas map field names to specs:
"help": "One line shown under the control", // optional, admin config only "help": "One line shown under the control", // optional, admin config only
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only "options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
"optionsRoute": "/devices", // "select" only, admin config only "optionsRoute": "/devices", // "select" only, admin config only
"optionsDependsOn": "<other key>" // with optionsRoute; see below "optionsDependsOn": "<other key>", // with optionsRoute; see below
"orderable": true, // "multiselect", admin config only
"addLabel": "Add a service type…" // with "orderable"; the dropdown's own line
} }
``` ```
@@ -242,8 +244,22 @@ marked, so nothing is dropped behind anyone's back. In a tile's gear popover
it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never it also takes `columns` (2–4) and `allByDefault: true`: a tile that has never
chosen shows every option on and saves nothing until someone flips one, so chosen shows every option on and saves nothing until someone flips one, so
options that turn up later are on as well (give it no `default` then). options that turn up later are on as well (give it no `default` then).
A `multiselect` with **`orderable: true`** stops being a ticklist. It renders
as a list built on purpose — the picks, numbered in their saved order, each
with a nudge either way and a remove — over a dropdown (`addLabel` names it)
holding everything not picked yet, `group` and all. The array is stored in
that order, so a module can read it as a priority. It is for a setting whose
ORDER means something, and it is also what to reach for when the list is long:
an account with forty service types is forty switches to read past, but one
dropdown and two picked rows. `pco-plan`'s service types are the worked
example — several are followed, and when two of them have a plan at the same
moment the one higher up wins. Admin config only; the tile gear's own
multiselect ignores it.
`select` and `multiselect` may use `optionsRoute` in `instanceSchema` too — `select` and `multiselect` may use `optionsRoute` in `instanceSchema` too —
the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does. the popover GETs `/api/modules/<id><optionsRoute>` like the admin page does.
`optionsDependsOn` follows any field, not just a select: a multiselect hands
its values over comma-joined.
`template` (tile settings) is a text with placeholders, built by dragging or `template` (tile settings) is a text with placeholders, built by dragging or
clicking the variables the module offers into it: `"variables": [ { "value": clicking the variables the module offers into it: `"variables": [ { "value":
@@ -672,8 +688,11 @@ module.exports = {
tile added from an entry only when that entry (or one of its `aliases`) is tile added from an entry only when that entry (or one of its `aliases`) is
in your list right now; otherwise the tile keeps its place in the layout, in your list right now; otherwise the tile keeps its place in the layout,
draws nothing, and appears the moment your list offers it again — a draws nothing, and appears the moment your list offers it again — a
Service timer waits for PCO Plan, a transcript for its channel. So your Service timer waits for PCO Plan, a transcript for its channel. The
client is never asked to render an entry you did not offer at mount time. operator can remove such a tile from the Layouts menu's **Not shown**
section, so an entry you retire for good (a send box that moved to another
module) leaves no dead cells behind. So your client is never asked to
render an entry you did not offer at mount time.
One case is still yours: an entry that vanishes *after* the tile mounted One case is still yours: an entry that vanishes *after* the tile mounted
(a timer deleted in ProPresenter mid-service). Render nothing for it — no (a timer deleted in ProPresenter mid-service). Render nothing for it — no
placeholder, no error — and the shell folds the tile away at its next placeholder, no error — and the shell folds the tile away at its next
@@ -1028,7 +1047,8 @@ client-side failures (a tile that fails to start says so in the tile).
**Poke the pieces directly:** **Poke the pieces directly:**
- `GET /api/modules` — what the shell sees (your manifest + non-secret config). - `GET /api/modules` — what the shell sees (your manifest + non-secret
config; `inactive` names the installed modules that are not running).
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE: - `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
`curl -N http://localhost:24500/api/modules/<id>/stream`. `curl -N http://localhost:24500/api/modules/<id>/stream`.
- `GET /api/admin/state` — enabled flags, health, mount errors. - `GET /api/admin/state` — enabled flags, health, mount errors.
+38 -20
View File
@@ -14,6 +14,7 @@
import createTeams from "./teams-client.js"; import createTeams from "./teams-client.js";
import createKey from "./key-client.js"; import createKey from "./key-client.js";
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
import { prettyKey } from "./key-notation.js"; import { prettyKey } from "./key-notation.js";
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>'; const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
@@ -38,6 +39,10 @@ function createPlan({ root, moduleApi }) {
let lastCurrentId = null; let lastCurrentId = null;
let lastListKey = ''; let lastListKey = '';
let absView = null; // what the server says AbleSet is doing, if anything let absView = null; // what the server says AbleSet is doing, if anything
// The plan this dashboard is looking at — usually the one the server
// follows, otherwise one of the others it also reads.
let view = viewOf(null);
let offViewed = null;
let userScrolledAt = 0; let userScrolledAt = 0;
root.innerHTML = ` root.innerHTML = `
@@ -89,6 +94,7 @@ function createPlan({ root, moduleApi }) {
moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) }); moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) });
applyPrefs(); applyPrefs();
} }
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) }); moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) }); moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
@@ -144,7 +150,7 @@ function createPlan({ root, moduleApi }) {
/* ── which items to show ────────────────────────────────────────── */ /* ── which items to show ────────────────────────────────────────── */
function visibleItems() { function visibleItems() {
const items = Array.isArray(state?.items) ? state.items : []; const items = Array.isArray(view.items) ? view.items : [];
return items.filter((it) => { return items.filter((it) => {
if (it.itemType === 'header' && !on('showHeaders')) return false; if (it.itemType === 'header' && !on('showHeaders')) return false;
if (it.servicePosition === 'pre' && !on('showPreService')) return false; if (it.servicePosition === 'pre' && !on('showPreService')) return false;
@@ -194,14 +200,14 @@ function createPlan({ root, moduleApi }) {
/** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */ /** Placeholder → text; a hidden or empty value resolves to '' and drops its segment. */
function templateValues() { function templateValues() {
const plan = state?.plan || {}; const plan = view.plan || {};
const items = state?.items || []; const items = state?.items || [];
const planned = plannedLength(); const planned = plannedLength();
return { return {
series: on('showSeries') ? plan.seriesTitle || '' : '', series: on('showSeries') ? plan.seriesTitle || '' : '',
part: on('showPart') ? plan.title || '' : '', part: on('showPart') ? plan.title || '' : '',
title: on('showPart') ? plan.title || '' : '', title: on('showPart') ? plan.title || '' : '',
servicetype: on('showServiceType') ? state?.serviceType?.name || '' : '', servicetype: on('showServiceType') ? view.name || '' : '',
date: on('showDate') ? plan.dates || '' : '', date: on('showDate') ? plan.dates || '' : '',
shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '', shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '',
time: on('showServiceTime') && plan.serviceStartsAt time: on('showServiceTime') && plan.serviceStartsAt
@@ -239,12 +245,12 @@ function createPlan({ root, moduleApi }) {
} }
function renderHead() { function renderHead() {
const plan = state?.plan; const plan = view.plan;
const show = on('showTopBar') && plan; const show = on('showTopBar') && plan;
headEl.hidden = !show; headEl.hidden = !show;
if (!show) return; if (!show) return;
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues()); const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
planTitleEl.textContent = line1 || line2 || state.serviceType?.name || 'Plan'; planTitleEl.textContent = line1 || line2 || view.name || 'Plan';
planSubEl.textContent = line1 ? line2 || '' : ''; planSubEl.textContent = line1 ? line2 || '' : '';
planSubEl.hidden = !planSubEl.textContent; planSubEl.hidden = !planSubEl.textContent;
renderClock(); renderClock();
@@ -258,9 +264,12 @@ function createPlan({ root, moduleApi }) {
* time once it has ended. * time once it has ended.
*/ */
function renderClock() { function renderClock() {
const plan = state?.plan; const plan = view.plan;
const live = state?.live || {}; const live = view.live ? (state?.live || {}) : {};
const svc = state?.service || {}; // The service window belongs to the type being followed. Another type's
// plan must not borrow its clock — it would read as that service running.
// With none, the fall-through below shows that plan's own start time.
const svc = view.live ? (state?.service || {}) : {};
clockLabelEl.textContent = ''; clockLabelEl.textContent = '';
clockValueEl.textContent = ''; clockValueEl.textContent = '';
clockValueEl.className = 'pp-clock-value'; clockValueEl.className = 'pp-clock-value';
@@ -272,7 +281,7 @@ function createPlan({ root, moduleApi }) {
clockValueEl.textContent = fmtDur((now - startedAt) / 1000); clockValueEl.textContent = fmtDur((now - startedAt) / 1000);
if (live.following) { if (live.following) {
// Ahead of / behind the plan: elapsed minus what the completed items were planned to take. // Ahead of / behind the plan: elapsed minus what the completed items were planned to take.
const done = state.items.filter((it) => live.history?.[it.id]?.endedAt); const done = view.items.filter((it) => live.history?.[it.id]?.endedAt);
const planned = done.reduce((sum, it) => sum + (it.length || 0), 0); const planned = done.reduce((sum, it) => sum + (it.length || 0), 0);
const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0); const actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0);
const drift = actual - planned; const drift = actual - planned;
@@ -321,13 +330,13 @@ function createPlan({ root, moduleApi }) {
link: { href: '/admin', text: 'Open Admin' }, link: { href: '/admin', text: 'Open Admin' },
}; };
} }
if (!state.reachable && !state.plan) { if (!state.reachable && !view.plan) {
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' }; return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
} }
if (!state.plan) { if (!view.plan) {
return { return {
title: 'No plan found', title: 'No plan found',
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans in Planning Center.`, hint: `${view.name || 'This service type'} has no upcoming or recent plans in Planning Center.`,
}; };
} }
if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' }; if (!visibleItems().length) return { title: 'Nothing to show', hint: 'Every item is hidden by this tile’s settings (gear menu).' };
@@ -483,7 +492,7 @@ function createPlan({ root, moduleApi }) {
// Rebuild rows only when the plan content or the display settings // Rebuild rows only when the plan content or the display settings
// changed; live highlighting and timers are applied in place. // changed; live highlighting and timers are applied in place.
const key = JSON.stringify([state.plan.id, state.items, cfg(), state.live?.following]); const key = JSON.stringify([view.id, view.plan.id, view.items, cfg(), view.live && state.live?.following]);
if (key !== lastListKey) { if (key !== lastListKey) {
lastListKey = key; lastListKey = key;
listEl.innerHTML = ''; listEl.innerHTML = '';
@@ -523,6 +532,7 @@ function createPlan({ root, moduleApi }) {
/** The row AbleSet's song belongs to — only ever the current one. */ /** The row AbleSet's song belongs to — only ever the current one. */
function absRow() { function absRow() {
const a = absView; const a = absView;
if (!view.live) return null; // another service type is on screen
if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null; if (!a || !a.available || !a.reachable || !a.song || !a.itemId) return null;
if (a.itemId !== lastCurrentId) return null; if (a.itemId !== lastCurrentId) return null;
return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null; return listEl.querySelector(`.pp-row[data-id="${CSS.escape(a.itemId)}"]`) || null;
@@ -627,15 +637,17 @@ function createPlan({ root, moduleApi }) {
/** Current / done classes and the elapsed timers, without rebuilding rows. */ /** Current / done classes and the elapsed timers, without rebuilding rows. */
function renderLiveMarks() { function renderLiveMarks() {
const live = state?.live || {}; // Only the followed service type has a live item: this dashboard may be
// looking at another one, which has no ProPresenter and no clock.
const live = view.live ? (state?.live || {}) : {};
const now = serverNow(); const now = serverNow();
const currentId = live.following ? live.currentItemId || '' : ''; const currentId = live.following ? live.currentItemId || '' : '';
const history = live.history || {}; const history = live.history || {};
const byId = new Map(state.items.map((it) => [it.id, it])); const byId = new Map(view.items.map((it) => [it.id, it]));
// Everything before the current item counts as done, matched or not. // Everything before the current item counts as done, matched or not.
const doneIds = new Set(); const doneIds = new Set();
if (currentId) { if (currentId) {
for (const it of state.items) { for (const it of view.items) {
if (it.id === currentId) break; if (it.id === currentId) break;
if (it.itemType !== 'header') doneIds.add(it.id); if (it.itemType !== 'header') doneIds.add(it.id);
} }
@@ -676,8 +688,8 @@ function createPlan({ root, moduleApi }) {
/* ── footer: what ProPresenter is doing ─────────────────────────── */ /* ── footer: what ProPresenter is doing ─────────────────────────── */
function renderFoot() { function renderFoot() {
const live = state?.live; const live = view.live ? state?.live : null;
const show = on('showLiveFooter') && live?.following && state?.plan; const show = on('showLiveFooter') && live?.following && view.plan;
footEl.hidden = !show; footEl.hidden = !show;
footEl.className = 'pp-foot'; footEl.className = 'pp-foot';
if (!show) return; if (!show) return;
@@ -713,7 +725,10 @@ function createPlan({ root, moduleApi }) {
/* ── render + ticking ───────────────────────────────────────────── */ /* ── render + ticking ───────────────────────────────────────────── */
function render() { function render() {
view = viewOf(state);
switcher.sync();
wrap.classList.toggle('is-offline', feedOffline); wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
wrap.classList.toggle('tint-types', Boolean(cfg().tintByType)); wrap.classList.toggle('tint-types', Boolean(cfg().tintByType));
wrap.classList.toggle('header-gradient', cfg().headerGradient !== false); wrap.classList.toggle('header-gradient', cfg().headerGradient !== false);
const color = (value, fallback) => { const color = (value, fallback) => {
@@ -744,9 +759,9 @@ function createPlan({ root, moduleApi }) {
function tick() { function tick() {
renderAbleSet(); renderAbleSet();
if (!state?.plan) return; if (!view.plan) return;
renderClock(); renderClock();
if (state.live?.following && on('showItemRuntime')) renderLiveMarks(); if (view.live && state.live?.following && on('showItemRuntime')) renderLiveMarks();
} }
/* ── live feed ──────────────────────────────────────────────────── */ /* ── live feed ──────────────────────────────────────────────────── */
@@ -805,11 +820,14 @@ function createPlan({ root, moduleApi }) {
.then((body) => { if (body && !state) accept(body); }) .then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ }); .catch(() => { /* the stream will cover it */ });
connect(); connect();
offViewed = onViewedType(() => { lastListKey = ''; render(); });
listEl.addEventListener('wheel', onUserScroll, { passive: true }); listEl.addEventListener('wheel', onUserScroll, { passive: true });
listEl.addEventListener('touchmove', onUserScroll, { passive: true }); listEl.addEventListener('touchmove', onUserScroll, { passive: true });
ticker = setInterval(tick, 1000); ticker = setInterval(tick, 1000);
}, },
stop() { stop() {
offViewed?.();
offViewed = null;
clearInterval(ticker); clearInterval(ticker);
ticker = null; ticker = null;
stream?.close(); stream?.close();
+19 -3
View File
@@ -5,11 +5,14 @@
which item is current comes from the ProPresenter module, as ever. */ which item is current comes from the ProPresenter module, as ever. */
import { parseKey, prettyKey } from "./key-notation.js"; import { parseKey, prettyKey } from "./key-notation.js";
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
export default function createKey({ root, moduleApi }) { export default function createKey({ root, moduleApi }) {
let state = null; let state = null;
let stream = null; let stream = null;
let feedOffline = false; let feedOffline = false;
let view = viewOf(null);
let offViewed = null;
root.innerHTML = ` root.innerHTML = `
<div class="pk-wrap"> <div class="pk-wrap">
@@ -23,17 +26,21 @@ export default function createKey({ root, moduleApi }) {
const nameEl = root.querySelector('.pk-name'); const nameEl = root.querySelector('.pk-name');
const nextEl = root.querySelector('.pk-next'); const nextEl = root.querySelector('.pk-next');
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
const cfg = () => moduleApi.instanceSettings || {}; const cfg = () => moduleApi.instanceSettings || {};
const on = (key) => cfg()[key] !== false; // unset = shown const on = (key) => cfg()[key] !== false; // unset = shown
/* ── the item on screen, and the one after it ───────────────────── */ /* ── the item on screen, and the one after it ───────────────────── */
function items() { function items() {
return Array.isArray(state?.items) ? state.items : []; return Array.isArray(view.items) ? view.items : [];
} }
function currentItem() { function currentItem() {
const id = state?.live?.following ? state.live.currentItemId : ''; // Only the followed service type has a live item; on another one there
// is nothing current, so the tile says so rather than guessing.
const id = view.live && state?.live?.following ? state.live.currentItemId : '';
return id ? items().find((it) => it.id === id) || null : null; return id ? items().find((it) => it.id === id) || null : null;
} }
@@ -63,6 +70,7 @@ export default function createKey({ root, moduleApi }) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…'); return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
} }
const item = currentItem(); const item = currentItem();
if (!view.live) return moduleApi.setStatus('ok', `${view.name || 'Another service type'} — not the one live`);
if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current'); if (!state.live?.following) return moduleApi.setStatus('ok', 'Not following ProPresenter — no item is current');
// The key on screen stays — the band still needs it — but a tile must // The key on screen stays — the band still needs it — but a tile must
// never look confidently live while the feed that chose it is gone. // never look confidently live while the feed that chose it is gone.
@@ -101,7 +109,10 @@ export default function createKey({ root, moduleApi }) {
} }
function render() { function render() {
view = viewOf(state);
switcher.sync();
wrap.classList.toggle('is-offline', feedOffline); wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
const live = state?.live; const live = state?.live;
wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable))); wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable)));
const color = String(cfg().keyColor || '').trim(); const color = String(cfg().keyColor || '').trim();
@@ -120,7 +131,9 @@ export default function createKey({ root, moduleApi }) {
} }
if (!item) { if (!item) {
drawKey('—'); drawKey('—');
nameEl.textContent = state.live?.following ? 'Nothing live' : 'Not following ProPresenter'; nameEl.textContent = !view.live
? (view.name || 'Another service type')
: (state.live?.following ? 'Nothing live' : 'Not following ProPresenter');
nextEl.textContent = ''; nextEl.textContent = '';
return; return;
} }
@@ -167,9 +180,12 @@ export default function createKey({ root, moduleApi }) {
.then((body) => { if (body && !state) accept(body); }) .then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ }); .catch(() => { /* the stream will cover it */ });
connect(); connect();
offViewed = onViewedType(render);
render(); render();
}, },
stop() { stop() {
offViewed?.();
offViewed = null;
stream?.close(); stream?.close();
stream = null; stream = null;
root.innerHTML = ''; root.innerHTML = '';
+11 -8
View File
@@ -1,8 +1,8 @@
{ {
"id": "pco-plan", "id": "pco-plan",
"name": "PCO Plan", "name": "PCO Plan",
"version": "1.9.0", "version": "1.10.0",
"proddash": ">=1.4.0", "proddash": ">=1.12.0",
"description": "Order of service, team assignments and the song key from Planning Center Services", "description": "Order of service, team assignments and the song key from Planning Center Services",
"provides": [ "provides": [
"timers" "timers"
@@ -46,11 +46,13 @@
"default": "", "default": "",
"group": "Planning Center credentials" "group": "Planning Center credentials"
}, },
"serviceTypeId": { "serviceTypes": {
"type": "select", "type": "multiselect",
"label": "Service type", "label": "Service types",
"default": "", "orderable": true,
"optionsRoute": "/service-types" "optionsRoute": "/service-types",
"help": "Add every service type whose plans this ProdDash should follow. Order is priority: when two of them have a plan at the same date and time, the one higher up wins.",
"addLabel": "Add a service type…"
}, },
"planId": { "planId": {
"type": "select", "type": "select",
@@ -63,7 +65,8 @@
} }
], ],
"optionsRoute": "/plans", "optionsRoute": "/plans",
"optionsDependsOn": "serviceTypeId" "optionsDependsOn": "serviceTypes",
"help": "Leave on Automatic for services. Pin a specific plan for rehearsals or to review a past service."
}, },
"pollSeconds": { "pollSeconds": {
"type": "number", "type": "number",
+235 -54
View File
@@ -73,6 +73,7 @@ const path = require('path');
const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching'); const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching');
const { const {
MINUTE, MINUTE,
DEFAULT_SERVICE_LENGTH_MS,
serviceTimes, serviceTimes,
serviceWindows, serviceWindows,
selectService, selectService,
@@ -111,6 +112,12 @@ const SEEN_DOCUMENTS_MAX = 200;
* plan changes, and otherwise no more often than this. * plan changes, and otherwise no more often than this.
*/ */
const TEAMS_MIN_MS = 60 * 1000; const TEAMS_MIN_MS = 60 * 1000;
/**
* The selected service types nobody is following: their plans only matter to a
* dashboard that has switched to one, and they never move during a service, so
* they ride a lane slower than the plan itself.
*/
const OTHERS_MIN_MS = 45 * 1000;
/** How long the service type's own team list is believed. */ /** How long the service type's own team list is believed. */
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE; const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */ /** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
@@ -147,6 +154,14 @@ function clearedState() {
items: [], items: [],
teams: [], // who is scheduled, by team (the Teams tile) teams: [], // who is scheduled, by team (the Teams tile)
teamsError: '', // why the last team read failed, if it did teamsError: '', // why the last team read failed, if it did
serviceTypes: [], // every selected service type id, highest priority first
/**
* The selected service types this ProdDash is NOT following right now,
* each with its own current plan. A dashboard can switch to one of these
* for itself; the live machinery (ProPresenter, the service clock, the
* timers) stays with the one above, which is the service actually running.
*/
others: {},
live: clearedLive(), live: clearedLive(),
service: clearedService(), service: clearedService(),
timing: null, // the admin's service-timing choices (no secrets) timing: null, // the admin's service-timing choices (no secrets)
@@ -610,7 +625,20 @@ module.exports = {
const streams = new Set(); // /stream clients (tiles) const streams = new Set(); // /stream clients (tiles)
const timerStreams = new Set(); // /timers/stream clients (the Timers module) const timerStreams = new Set(); // /timers/stream clients (the Timers module)
const pco = createPcoClient(config); const pco = createPcoClient(config);
const serviceTypeId = String(config.serviceTypeId || '').trim(); /**
* Every service type this ProdDash follows, highest priority first. A
* config written before 1.10.0 has the single `serviceTypeId` instead;
* it is read as a list of one so a booth keeps working untouched until
* somebody opens the admin page.
*/
const serviceTypes = (Array.isArray(config.serviceTypes) ? config.serviceTypes : [])
.map((v) => String(v).trim())
.filter(Boolean);
if (!serviceTypes.length && String(config.serviceTypeId || '').trim()) {
serviceTypes.push(String(config.serviceTypeId).trim());
}
/** The one whose plan is on screen — everything plan-scoped hangs off this. */
let activeTypeId = serviceTypes[0] || '';
const pinnedPlanId = String(config.planId || '').trim(); const pinnedPlanId = String(config.planId || '').trim();
const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000; const pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000;
const follow = config.followProPresenter !== false; const follow = config.followProPresenter !== false;
@@ -647,8 +675,9 @@ module.exports = {
let state = clearedState(); let state = clearedState();
state.configured = pco.configured; state.configured = pco.configured;
state.serviceTypeSet = Boolean(serviceTypeId); state.serviceTypeSet = Boolean(serviceTypes.length);
state.serviceType = { id: serviceTypeId, name: '' }; state.serviceType = { id: activeTypeId, name: '' };
state.serviceTypes = serviceTypes.slice();
state.live.following = follow; state.live.following = follow;
state.timing = { ...timing }; state.timing = { ...timing };
@@ -704,8 +733,37 @@ module.exports = {
/* — plan reading — */ /* — plan reading — */
function planPath(planId) { function planPath(planId, stId = activeTypeId) {
return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`; return `${SERVICES}/service_types/${encodeURIComponent(stId)}/plans/${encodeURIComponent(planId)}`;
}
/** id → name, asked once each. A service type that has gone is named, not fatal. */
const typeNames = new Map();
async function typeName(stId) {
if (typeNames.has(stId)) return typeNames.get(stId);
let name = `Service type ${stId}`;
try {
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}`);
name = String(st?.data?.attributes?.name || '').trim() || name;
} catch (err) {
if (err.status !== 404) throw err;
name = `Service type ${stId} (no longer in Planning Center)`;
}
typeNames.set(stId, name);
return name;
}
/**
* A pinned plan, as the admin's Plan select writes it: "<serviceType>/<plan>".
* A bare id is a pin saved before 1.10.0, when there was only one service
* type to belong to.
*/
function parsePin(raw) {
const text = String(raw || '').trim();
if (!text) return null;
const slash = text.indexOf('/');
if (slash > 0) return { stId: text.slice(0, slash), planId: text.slice(slash + 1) };
return { stId: serviceTypes[0] || '', planId: text };
} }
/** The plans around now for a service type: a few upcoming, a few recent, oldest first. */ /** The plans around now for a service type: a few upcoming, a few recent, oldest first. */
@@ -740,8 +798,61 @@ module.exports = {
return pickPlan(await listPlans(stId)); return pickPlan(await listPlans(stId));
} }
async function readPlanTimes(planId) { /**
const body = await pco.getAll(`${planPath(planId)}/plan_times`); * The plan to follow, across every selected service type. Their plans are
* ranked together — one happening today beats one still to come, which
* beats one already past — and a dead heat on the date is settled by the
* order the service types are listed in, which is what that order is for.
*
* One service type failing (a deleted id, say) must not cost the booth the
* others, so it is logged and skipped; a credentials or network failure is
* not skippable and is thrown on.
*/
async function choosePlanAcross() {
const now = Date.now();
const today = localYmd(new Date(now));
const candidates = [];
for (let priority = 0; priority < serviceTypes.length; priority += 1) {
const stId = serviceTypes[priority];
let plans;
try {
plans = await listPlans(stId);
} catch (err) {
if (err.status === 401 || err.status === 429 || err.network) throw err;
log(`service type ${stId} skipped: ${err?.message || err}`);
continue;
}
for (const plan of plans) {
const tier = localYmd(new Date(plan.sortDate)) === today ? 0 : (plan.sortDate > now ? 1 : 2);
candidates.push({ ...plan, stId, priority, tier });
}
}
if (!candidates.length) return null;
candidates.sort((a, b) => (
a.tier - b.tier
// today and upcoming: soonest first. Past: most recent first.
|| (a.tier === 2 ? b.sortDate - a.sortDate : a.sortDate - b.sortDate)
|| a.priority - b.priority
));
return candidates[0];
}
/** Point everything plan-scoped at a service type, forgetting the last one's. */
async function useServiceType(stId) {
if (!stId || stId === activeTypeId) {
if (stId && !state.serviceType.name) state.serviceType = { id: stId, name: await typeName(stId) };
return;
}
activeTypeId = stId;
// Note categories belong to the service type; the team lists are already
// cached per type and look after themselves.
noteCategories = { at: 0, options: [] };
state.serviceType = { id: stId, name: await typeName(stId) };
if (serviceTypes.length > 1) log(`following service type "${state.serviceType.name}"`);
}
async function readPlanTimes(planId, stId = activeTypeId) {
const body = await pco.getAll(`${planPath(planId, stId)}/plan_times`);
return (body.data || []) return (body.data || [])
.map((t) => ({ .map((t) => ({
id: String(t.id), id: String(t.id),
@@ -754,8 +865,8 @@ module.exports = {
.sort((x, y) => x.startsAt - y.startsAt); .sort((x, y) => x.startsAt - y.startsAt);
} }
async function readItems(planId) { async function readItems(planId, stId = activeTypeId) {
const target = `${planPath(planId)}/items`; const target = `${planPath(planId, stId)}/items`;
try { try {
return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' })); return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' }));
} catch (err) { } catch (err) {
@@ -775,18 +886,21 @@ module.exports = {
* service type, not during a service. A failed read is not fatal — 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. * plan's own teams still draw, just without the empty ones.
*/ */
let serviceTeams = { at: 0, list: [] }; const serviceTeamsByType = new Map(); // stId -> { at, list }
async function readServiceTeams() { async function readServiceTeams(stId = activeTypeId) {
let serviceTeams = serviceTeamsByType.get(stId) || { at: 0, list: [] };
serviceTeamsByType.set(stId, serviceTeams);
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list; if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
try { try {
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/teams`); const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(stId)}/teams`);
const list = (body.data || []).map((t, i) => ({ const list = (body.data || []).map((t, i) => ({
id: String(t.id), id: String(t.id),
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`, name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
sequence: Number(t.attributes?.sequence) || i + 1, sequence: Number(t.attributes?.sequence) || i + 1,
})); }));
serviceTeams = { at: Date.now(), list }; serviceTeams = { at: Date.now(), list };
serviceTeamsByType.set(stId, serviceTeams);
} catch (err) { } catch (err) {
log(`service type team list read failed: ${err?.message || err}`); log(`service type team list read failed: ${err?.message || err}`);
serviceTeams.at = Date.now(); // don't retry every poll serviceTeams.at = Date.now(); // don't retry every poll
@@ -795,7 +909,7 @@ module.exports = {
} }
/** planPersonId → photo URL, so a browser never learns the CDN address. */ /** planPersonId → photo URL, so a browser never learns the CDN address. */
let teamPhotos = new Map(); const teamPhotos = new Map();
let teamsReadAt = 0; let teamsReadAt = 0;
/** /**
@@ -804,8 +918,8 @@ module.exports = {
* them, exactly as the items read does: a tile that can name people * them, exactly as the items read does: a tile that can name people
* without photos is far better than a tile that shows nothing. * without photos is far better than a tile that shows nothing.
*/ */
async function readTeams(planId) { async function readTeams(planId, stId = activeTypeId) {
const target = `${planPath(planId)}/team_members`; const target = `${planPath(planId, stId)}/team_members`;
let body; let body;
try { try {
body = await pco.getAll(target, { include: 'team,person' }); body = await pco.getAll(target, { include: 'team,person' });
@@ -817,16 +931,16 @@ module.exports = {
throw err; throw err;
} }
} }
const teams = shapeTeams(body, await readServiceTeams()); const teams = shapeTeams(body, await readServiceTeams(stId));
const photos = new Map();
const included = new Map(); const included = new Map();
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec); for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
for (const pp of body.data || []) { for (const pp of body.data || []) {
const personRef = pp.relationships?.person?.data; const personRef = pp.relationships?.person?.data;
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null); const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
if (url) photos.set(String(pp.id), url); // Photos accumulate: a dashboard looking at another service type still
// asks this module for its people's faces.
if (url) teamPhotos.set(String(pp.id), url);
} }
teamPhotos = photos;
teamsReadAt = Date.now(); teamsReadAt = Date.now();
return teams; return teams;
} }
@@ -854,6 +968,57 @@ module.exports = {
} }
} }
/**
* The other selected service types, each with its own current plan, for a
* dashboard that has switched to one. Read on the slow lane and wrapped
* individually: a service type that fails here is reported in its own
* entry and costs the booth nothing else.
*/
let othersReadAt = 0;
async function readOtherTypes(force) {
if (!force && Date.now() - othersReadAt < OTHERS_MIN_MS) return;
othersReadAt = Date.now();
const others = {};
for (const stId of serviceTypes) {
if (stId === activeTypeId) continue;
const entry = { id: stId, name: '', plan: null, items: [], teams: [], teamsError: '', lastError: '' };
others[stId] = entry;
try {
entry.name = await typeName(stId);
const chosen = await choosePlanId(stId);
if (!chosen) continue;
const [planTimes, items] = await Promise.all([
readPlanTimes(chosen.id, stId),
readItems(chosen.id, stId),
]);
const plan = shapePlan(chosen, stId, planTimes);
// No service clock runs for a type nobody is following, so take the
// service that is current or next purely to lay the items out.
const times = serviceTimes(planTimes);
const now = Date.now();
const service = times.find((t) => (t.endsAt || t.startsAt + DEFAULT_SERVICE_LENGTH_MS) > now) || times[times.length - 1];
if (service) {
plan.serviceStartsAt = service.startsAt;
plan.serviceEndsAt = service.endsAt || 0;
plan.serviceName = service.name || '';
}
scheduleItems(items, plan.serviceStartsAt || plan.sortDate);
entry.plan = plan;
entry.items = items;
try {
entry.teams = await readTeams(chosen.id, stId);
} catch (err) {
entry.teamsError = err?.message || String(err);
}
} catch (err) {
entry.lastError = err?.message || String(err);
log(`service type ${stId}: ${entry.lastError}`);
}
}
state.others = others;
}
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */ /** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
function teamOptions() { function teamOptions() {
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) }; return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
@@ -861,23 +1026,22 @@ module.exports = {
async function readPlan() { async function readPlan() {
if (!pco.configured || !serviceTypeId) return; if (!pco.configured || !serviceTypes.length) return;
let planRec = null; let planRec = null;
if (pinnedPlanId) { const pin = parsePin(pinnedPlanId);
planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null; if (pin && pin.planId && pin.stId) {
await useServiceType(pin.stId);
planRec = (await pco.get(planPath(pin.planId, pin.stId)))?.data || null;
} else { } else {
const chosen = await choosePlanId(serviceTypeId); const chosen = await choosePlanAcross();
// Re-read the chosen plan by id: the list endpoint's attributes are the if (chosen) {
// same, but this keeps one code path and picks up edits immediately. await useServiceType(chosen.stId);
planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null; // Re-read the chosen plan by id: the list endpoint's attributes are
} // the same, but this keeps one code path and picks up edits at once.
if (!state.serviceType.name) { planRec = (await pco.get(planPath(chosen.rec.id, chosen.stId)))?.data || chosen.rec;
try { } else {
const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`); // Nothing anywhere: still name a service type so the tile can say whose.
state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') }; await useServiceType(serviceTypes[0]);
} catch (err) {
if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`);
throw err;
} }
} }
if (!planRec) { if (!planRec) {
@@ -890,10 +1054,11 @@ module.exports = {
state.lastError = ''; state.lastError = '';
state.lastFetched = Date.now(); state.lastFetched = Date.now();
syncServiceSafe(Date.now(), null); syncServiceSafe(Date.now(), null);
await readOtherTypes(true);
return; return;
} }
const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]); const [planTimes, items] = await Promise.all([readPlanTimes(planRec.id), readItems(planRec.id)]);
const plan = shapePlan(planRec, serviceTypeId, planTimes); const plan = shapePlan(planRec, activeTypeId, planTimes);
const planChanged = state.plan?.id !== plan.id; const planChanged = state.plan?.id !== plan.id;
if (planChanged) { if (planChanged) {
if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title}) — service timing reset`); if (state.plan) log(`plan changed: ${state.plan.id} → ${plan.id} (${plan.dates} ${plan.title}) — service timing reset`);
@@ -916,6 +1081,8 @@ module.exports = {
// Service selection, item schedule and the running clock all follow // Service selection, item schedule and the running clock all follow
// from the plan's service times — decided in one place. // from the plan's service times — decided in one place.
syncServiceSafe(Date.now(), null); syncServiceSafe(Date.now(), null);
// Whatever else is selected, for a dashboard that has switched to it.
await readOtherTypes(planChanged);
} }
/** Say, once per plan, which item a document named like the plan will land on. */ /** Say, once per plan, which item a document named like the plan will land on. */
@@ -943,11 +1110,11 @@ module.exports = {
* list — the gear window then says there is nothing to choose from yet. * list — the gear window then says there is nothing to choose from yet.
*/ */
async function noteCategoryOptions() { async function noteCategoryOptions() {
if (!pco.configured || !serviceTypeId) return []; if (!pco.configured || !activeTypeId) return [];
if (Date.now() - noteCategories.at < NOTE_CATEGORIES_TTL_MS) return noteCategories.options; if (Date.now() - noteCategories.at < NOTE_CATEGORIES_TTL_MS) return noteCategories.options;
if (!noteCategoriesInFlight) { if (!noteCategoriesInFlight) {
noteCategoriesInFlight = (async () => { noteCategoriesInFlight = (async () => {
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/item_note_categories`); const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(activeTypeId)}/item_note_categories`);
const seen = new Set(); const seen = new Set();
const options = (body.data || []) const options = (body.data || [])
.filter((c) => !c?.attributes?.deleted_at) .filter((c) => !c?.attributes?.deleted_at)
@@ -1713,11 +1880,11 @@ module.exports = {
if (!pco.configured) { if (!pco.configured) {
state.lastError = 'No Planning Center credentials — add a Client ID + Secret (or OAuth token) in /admin'; state.lastError = 'No Planning Center credentials — add a Client ID + Secret (or OAuth token) in /admin';
log(state.lastError); log(state.lastError);
} else if (!serviceTypeId) { } else if (!serviceTypes.length) {
state.lastError = 'No service type selected — choose one under PCO Plan in /admin'; state.lastError = 'No service type selected — choose one under PCO Plan in /admin';
log(state.lastError); log(state.lastError);
} else { } else {
log(`reading service type ${serviceTypeId} from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`); log(`reading ${serviceTypes.length} service type${serviceTypes.length === 1 ? '' : 's'} (${serviceTypes.join(', ')}) from ${API_BASE} (${pco.method})${pinnedPlanId ? `, plan ${pinnedPlanId} pinned` : ''}`);
schedulePoll(0); schedulePoll(0);
} }
log( log(
@@ -1777,6 +1944,7 @@ module.exports = {
}, },
listPlans, listPlans,
pickPlan, pickPlan,
typeName,
timersSnapshot, timersSnapshot,
documentOptions, documentOptions,
noteCategoryOptions, noteCategoryOptions,
@@ -1805,11 +1973,11 @@ module.exports = {
}, },
health() { health() {
if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' }; if (!pco.configured) return { status: 'error', message: 'No Planning Center credentials configured' };
if (!serviceTypeId) return { status: 'error', message: 'No service type selected — pick one in the Service type list below' }; if (!serviceTypes.length) return { status: 'error', message: 'No service type selected — tick one in the Service types list below' };
if (!state.reachable) { if (!state.reachable) {
return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' }; return { status: state.lastFetched ? 'error' : 'connecting', message: state.lastError || 'Reading the plan…' };
} }
const name = state.serviceType.name || serviceTypeId; const name = state.serviceType.name || activeTypeId;
const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found'; const plan = state.plan ? ` · ${state.plan.dates}${state.plan.title ? ` — ${state.plan.title}` : ''}` : ' · no plan found';
const service = win ? ` · ${serviceLabel(win)} ${svc.phase}` : ''; const service = win ? ` · ${serviceLabel(win)} ${svc.phase}` : '';
const live = wantFeed ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : ''; const live = wantFeed ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : '';
@@ -1986,8 +2154,15 @@ module.exports = {
} }
return names.join(' › '); return names.join(' › ');
}; };
const live = current.getState().serviceType?.id || '';
const options = serviceTypes const options = serviceTypes
.map((st) => ({ value: st.id, label: st.name, group: pathOf(st.parentId) })) .map((st) => ({
value: st.id,
// Which one's plan is actually on screen, so the list says so
// rather than leaving it to be worked out.
label: st.id === live ? `${st.name} — following now` : st.name,
group: pathOf(st.parentId),
}))
.sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label)); .sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
json(res, 200, { options }); json(res, 200, { options });
} catch (err) { } catch (err) {
@@ -1997,23 +2172,28 @@ module.exports = {
}, },
/** /**
* Options for the admin page's "Plan" select, for the service type the * Options for the admin page's "Plan" select, across every service type
* admin currently has picked (?serviceTypeId=…): the upcoming and recent * ticked above (?serviceTypes=a,b,c), grouped by which one each belongs
* plans, with the one Automatic would choose marked. * to. A value is "<serviceType>/<plan>", because a plan id alone does
* not say whose it is once there is more than one list.
*/ */
'GET /plans': async (req, res) => { 'GET /plans': async (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' }); if (!current) return json(res, 503, { error: 'Module not running.' });
const { pco } = current; const { pco } = current;
const params = new URLSearchParams(req.search || ''); const params = new URLSearchParams(req.search || '');
const stId = String(params.get('serviceTypeId') || '').trim(); const asked = String(params.get('serviceTypes') || '').split(',').map((v) => v.trim()).filter(Boolean);
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' }; const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
if (!pco.configured || !stId) return json(res, 200, { options: [auto] }); if (!pco.configured || !asked.length) return json(res, 200, { options: [auto] });
const options = [auto];
let failure = '';
for (const stId of asked) {
try { try {
const plans = await current.listPlans(stId, { future: 8, past: 6 }); const [name, plans] = await Promise.all([
current.typeName(stId),
current.listPlans(stId, { future: 8, past: 6 }),
]);
const picked = current.pickPlan(plans); const picked = current.pickPlan(plans);
const now = Date.now(); const now = Date.now();
const options = [auto];
// Upcoming first (soonest at the top), then the recent ones.
const ordered = [ const ordered = [
...plans.filter((p) => p.sortDate >= now - 12 * 3600000), ...plans.filter((p) => p.sortDate >= now - 12 * 3600000),
...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(), ...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(),
@@ -2024,13 +2204,14 @@ module.exports = {
if (a.series_title) bits.push(String(a.series_title)); if (a.series_title) bits.push(String(a.series_title));
if (a.title) bits.push(String(a.title)); if (a.title) bits.push(String(a.title));
let label = bits.join(' — '); let label = bits.join(' — ');
if (picked && rec.id === picked.id) label += ' (Automatic picks this)'; if (picked && rec.id === picked.id) label += ' (this one, on Automatic)';
options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' }); options.push({ value: `${stId}/${rec.id}`, label, group: name });
} }
json(res, 200, { options });
} catch (err) { } catch (err) {
json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) }); failure = err?.message || String(err);
} }
}
json(res, 200, failure && options.length === 1 ? { options, error: failure } : { options });
}, },
}; };
}, },
+48
View File
@@ -744,3 +744,51 @@
[data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; } [data-module="pco-plan"] .pk-wrap.is-stale .pk-key { opacity: 0.55; }
[data-module="pco-plan"] .pk-wrap.is-stale .pk-name, [data-module="pco-plan"] .pk-wrap.is-stale .pk-name,
[data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; } [data-module="pco-plan"] .pk-wrap.is-stale .pk-next { opacity: 0.6; }
/* ── the service-type menu, and a tile looking at another one ────────── */
[data-module="pco-plan"] .pp-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 9px;
background: none;
border: none;
border-radius: 6px;
color: var(--text);
font-size: 12px;
font-family: inherit;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
[data-module="pco-plan"] .pp-menu-item:hover { background: var(--border); }
[data-module="pco-plan"] .pp-menu-tick {
width: 12px;
flex: none;
color: var(--accent);
font-weight: 700;
visibility: hidden;
}
[data-module="pco-plan"] .pp-menu-item.is-on .pp-menu-tick { visibility: visible; }
[data-module="pco-plan"] .pp-menu-name { flex: 1; min-width: 0; font-weight: 600; }
[data-module="pco-plan"] .pp-menu-note {
flex: none;
font-size: 0.8em;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
/* Looking at a service type other than the one running: the top bar carries
a quiet stripe so nobody reads this as the live plan. */
[data-module="pco-plan"] .pp-wrap.is-other-type .pp-head,
[data-module="pco-plan"] .tm-wrap.is-other-type .tm-head {
background: color-mix(in srgb, var(--warn) 9%, transparent);
border-bottom-color: color-mix(in srgb, var(--warn) 45%, transparent);
}
[data-module="pco-plan"] .pk-wrap.is-other-type {
box-shadow: inset 0 2px 0 color-mix(in srgb, var(--warn) 45%, transparent);
}
+25 -13
View File
@@ -13,11 +13,15 @@ const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */ /** Colours for "Tint teams", walked in order so neighbouring teams differ. */
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50]; const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
export default function createTeams({ root, moduleApi }) { export default function createTeams({ root, moduleApi }) {
let state = null; let state = null;
let feedOffline = false; let feedOffline = false;
let stream = null; let stream = null;
let lastKey = ''; let lastKey = '';
let view = viewOf(null);
let offViewed = null;
root.innerHTML = ` root.innerHTML = `
<div class="tm-wrap"> <div class="tm-wrap">
@@ -39,6 +43,8 @@ export default function createTeams({ root, moduleApi }) {
/* ── title-bar controls ─────────────────────────────────────────── */ /* ── title-bar controls ─────────────────────────────────────────── */
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
const refreshBtn = moduleApi.header.addButton({ const refreshBtn = moduleApi.header.addButton({
icon: REFRESH_SVG, icon: REFRESH_SVG,
title: 'Re-read from Planning Center', title: 'Re-read from Planning Center',
@@ -92,7 +98,7 @@ export default function createTeams({ root, moduleApi }) {
/* ── which teams and people to show ─────────────────────────────── */ /* ── which teams and people to show ─────────────────────────────── */
function visibleTeams() { function visibleTeams() {
const teams = Array.isArray(state?.teams) ? state.teams : []; const teams = Array.isArray(view.teams) ? view.teams : [];
const wantTeams = chosen('teams'); const wantTeams = chosen('teams');
const out = []; const out = [];
for (const team of teams) { for (const team of teams) {
@@ -118,8 +124,8 @@ export default function createTeams({ root, moduleApi }) {
if (!state.reachable) { if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…'); return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
} }
if (state.teamsError) return moduleApi.setStatus('error', `Team list: ${state.teamsError}`); if (view.teamsError) return moduleApi.setStatus('error', `Team list: ${view.teamsError}`);
const people = (state.teams || []).reduce((n, t) => n + t.members.length, 0); const people = (view.teams || []).reduce((n, t) => n + t.members.length, 0);
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`); moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
} }
@@ -127,14 +133,14 @@ export default function createTeams({ root, moduleApi }) {
/** The counts describe what this tile shows, not what the plan holds. */ /** The counts describe what this tile shows, not what the plan holds. */
function templateValues() { function templateValues() {
const plan = state?.plan || {}; const plan = view.plan || {};
const teams = visibleTeams(); const teams = visibleTeams();
const people = teams.reduce((n, t) => n + t.members.length, 0); const people = teams.reduce((n, t) => n + t.members.length, 0);
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0); const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
return { return {
series: plan.seriesTitle || '', series: plan.seriesTitle || '',
part: plan.title || '', part: plan.title || '',
servicetype: state?.serviceType?.name || '', servicetype: view.name || '',
date: plan.dates || '', date: plan.dates || '',
shortdate: plan.shortDates || plan.dates || '', shortdate: plan.shortDates || plan.dates || '',
time: plan.serviceStartsAt time: plan.serviceStartsAt
@@ -171,11 +177,11 @@ export default function createTeams({ root, moduleApi }) {
} }
function renderHead() { function renderHead() {
const show = on('showTopBar') && state?.plan; const show = on('showTopBar') && view.plan;
headEl.hidden = !show; headEl.hidden = !show;
if (!show) return; if (!show) return;
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues()); const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
titleEl.textContent = line1 || line2 || state.serviceType?.name || 'Teams'; titleEl.textContent = line1 || line2 || view.name || 'Teams';
subEl.textContent = line1 ? line2 || '' : ''; subEl.textContent = line1 ? line2 || '' : '';
subEl.hidden = !subEl.textContent; subEl.hidden = !subEl.textContent;
} }
@@ -199,19 +205,19 @@ export default function createTeams({ root, moduleApi }) {
link: { href: '/admin', text: 'Open Admin' }, link: { href: '/admin', text: 'Open Admin' },
}; };
} }
if (!state.reachable && !state.plan) { if (!state.reachable && !view.plan) {
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' }; return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
} }
if (!state.plan) { if (!view.plan) {
return { return {
title: 'No plan found', title: 'No plan found',
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans.`, hint: `${view.name || 'This service type'} has no upcoming or recent plans.`,
}; };
} }
if (!(state.teams || []).length) { if (!(view.teams || []).length) {
return { return {
title: 'Nobody scheduled yet', title: 'Nobody scheduled yet',
hint: state.teamsError || 'This plan has no team members in Planning Center.', hint: view.teamsError || 'This plan has no team members in Planning Center.',
}; };
} }
if (!visibleTeams().length) { if (!visibleTeams().length) {
@@ -372,7 +378,7 @@ export default function createTeams({ root, moduleApi }) {
// Rebuild only when the roster or the settings actually changed: the // Rebuild only when the roster or the settings actually changed: the
// avatars are <img>s and a needless rebuild makes them flicker. // avatars are <img>s and a needless rebuild makes them flicker.
const teams = visibleTeams(); const teams = visibleTeams();
const key = JSON.stringify([teams, cfg()]); const key = JSON.stringify([view.id, teams, cfg()]);
if (key === lastKey) return; if (key === lastKey) return;
lastKey = key; lastKey = key;
bodyEl.innerHTML = ''; bodyEl.innerHTML = '';
@@ -380,7 +386,10 @@ export default function createTeams({ root, moduleApi }) {
} }
function render() { function render() {
view = viewOf(state);
switcher.sync();
wrap.classList.toggle('is-offline', feedOffline); wrap.classList.toggle('is-offline', feedOffline);
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows)); wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
wrap.classList.toggle('header-gradient', on('headerGradient')); wrap.classList.toggle('header-gradient', on('headerGradient'));
renderStatus(); renderStatus();
@@ -421,9 +430,12 @@ export default function createTeams({ root, moduleApi }) {
.then((body) => { if (body && !state) accept(body); }) .then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ }); .catch(() => { /* the stream will cover it */ });
connect(); connect();
offViewed = onViewedType(() => { lastKey = ''; render(); });
render(); render();
}, },
stop() { stop() {
offViewed?.();
offViewed = null;
stream?.close(); stream?.close();
stream = null; stream = null;
root.innerHTML = ''; root.innerHTML = '';
+171
View File
@@ -0,0 +1,171 @@
/* PCO Plan — which service type THIS dashboard is looking at.
The module's server follows one service type at a time: whichever the
admin's priority order lands on. A dashboard may want a different one on
screen — the other campus, the youth plan — without changing what the
server follows or what any other screen sees.
That choice belongs to the browser, so it lives here: one value for the
whole page, kept in localStorage and shared between every PCO Plan tile on
it, so switching from one tile moves them all together. */
const KEY = 'pco-plan:viewed-service-type';
/** One bus per page, however many tiles import this. */
function bus() {
if (!window.__pcoPlanViewed) {
let value = '';
try {
value = String(localStorage.getItem(KEY) || '');
} catch {
value = ''; // private window, storage off: the choice just won't persist
}
window.__pcoPlanViewed = { value, listeners: new Set() };
}
return window.__pcoPlanViewed;
}
/** '' means "whatever the server is following" — the normal case. */
export function viewedType() {
return bus().value;
}
export function setViewedType(id) {
const b = bus();
const next = String(id || '');
if (next === b.value) return;
b.value = next;
try {
if (next) localStorage.setItem(KEY, next);
else localStorage.removeItem(KEY);
} catch { /* not persisted, still shared for this page */ }
for (const fn of [...b.listeners]) {
try {
fn(next);
} catch (err) {
console.error('[pco-plan] viewed-type listener failed:', err);
}
}
}
/** Returns an unsubscribe — call it from the tile's stop(). */
export function onViewedType(fn) {
const b = bus();
b.listeners.add(fn);
return () => b.listeners.delete(fn);
}
/**
* The plan a tile should draw, given the whole state and this page's choice:
* the one the server follows, or one of the others it also reads.
*
* `live` is false for anything but the followed one — the ProPresenter match,
* the service clock and the timers belong to the service actually running, so
* a tile looking elsewhere must not pretend to have them.
*/
export function viewOf(state) {
const empty = { id: '', name: '', plan: null, items: [], teams: [], teamsError: '', live: true, lastError: '' };
if (!state) return empty;
const wanted = viewedType();
const activeId = state.serviceType?.id || '';
if (!wanted || wanted === activeId) {
return {
id: activeId,
name: state.serviceType?.name || '',
plan: state.plan,
items: state.items || [],
teams: state.teams || [],
teamsError: state.teamsError || '',
live: true,
lastError: '',
};
}
const other = (state.others || {})[wanted];
if (!other) {
// Ticked off in admin since this dashboard chose it, or not read yet.
return { ...empty, id: wanted, live: false, lastError: 'That service type is no longer selected' };
}
return {
id: other.id,
name: other.name,
plan: other.plan,
items: other.items || [],
teams: other.teams || [],
teamsError: other.teamsError || '',
live: false,
lastError: other.lastError || '',
};
}
/** Every service type a dashboard may switch to, in the admin's priority order. */
export function choices(state) {
const out = [];
const activeId = state?.serviceType?.id || '';
for (const id of state?.serviceTypes || []) {
if (id === activeId) out.push({ id, name: state.serviceType?.name || id, following: true });
else out.push({ id, name: (state.others || {})[id]?.name || id, following: false });
}
return out;
}
const SWITCH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h13l-3-3"/><path d="M20 16H7l3 3"/></svg>';
/**
* The title-bar control that switches this dashboard between the selected
* service types. Added by every PCO Plan tile, because switching from any of
* them moves all of them; it hides itself whenever there is only one service
* type to choose from, which is the usual case.
*
* Returns { sync } — call it when the state arrives so the control appears,
* disappears or re-labels itself.
*/
export function addServiceTypeSwitch({ moduleApi, getState }) {
const menu = moduleApi.header.addMenu({
icon: SWITCH_SVG,
title: 'Service type shown on this dashboard',
build(el) {
el.classList.add('pp-menu');
const state = getState();
const wanted = viewedType();
const rows = [
{ id: '', name: 'Whatever is live', following: false, auto: true },
...choices(state),
];
for (const row of rows) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'pp-menu-item' + (row.id === wanted ? ' is-on' : '');
const tick = document.createElement('span');
tick.className = 'pp-menu-tick';
tick.textContent = '✓';
const name = document.createElement('span');
name.className = 'pp-menu-name';
name.textContent = row.name;
item.append(tick, name);
if (row.following) {
const note = document.createElement('span');
note.className = 'pp-menu-note';
note.textContent = 'live';
item.appendChild(note);
}
item.addEventListener('click', () => {
setViewedType(row.id);
menu.close();
});
el.appendChild(item);
}
},
});
return {
sync() {
const state = getState();
// One service type is no choice at all — rule 11, nothing in the bar
// that has nothing to do.
const many = (state?.serviceTypes || []).length > 1;
menu.button.hidden = !many;
const wanted = viewedType();
menu.button.classList.toggle('active', Boolean(wanted) && wanted !== (state?.serviceType?.id || ''));
},
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "resi-cues", "id": "resi-cues",
"name": "Resi Cues", "name": "Resi Cues",
"version": "1.0.0", "version": "1.0.3",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Time until the next Resi cue point, on the board and as a timer", "description": "Time until the next Resi cue point, on the board and as a timer",
"provides": ["timers"], "provides": ["timers"],
+89 -14
View File
@@ -39,6 +39,9 @@ function msToPosition(total) {
const WINDOW_MS = 120000; // how long a reading stays useful const WINDOW_MS = 120000; // how long a reading stays useful
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session
/** /**
* Resi republishes a decoder's `position` only every few seconds, on an irregular * Resi republishes a decoder's `position` only every few seconds, on an irregular
@@ -61,6 +64,8 @@ function createPlayhead(options) {
let offsets = []; // { at, off } — off is mediaMs - wall time it was read let offsets = []; // { at, off } — off is mediaMs - wall time it was read
let spreads = []; // how far apart honest readings sit: this link's noise let spreads = []; // how far apart honest readings sit: this link's noise
let gaps = []; // how long between the playhead actually moving let gaps = []; // how long between the playhead actually moving
let changes = 0; // readings that differed from the one before
let rejected = false; // the last gap looked like a pause and was skipped
let base; // the trusted offset let base; // the trusted offset
let lastMedia; let lastMedia;
let lastNewAt; let lastNewAt;
@@ -90,15 +95,25 @@ function createPlayhead(options) {
spreads = spreads.concat(-delta).slice(-20); spreads = spreads.concat(-delta).slice(-20);
} }
} }
if (lastNewAt !== undefined) { // The first reading lands at an arbitrary point in Resi's cycle, so the gap
// from it to the next one is only a fraction of the real cadence. Learning
// from it locked in a 2s cadence that rejected every honest 30s gap.
if (lastNewAt !== undefined && changes > 0) {
const gap = t - lastNewAt; const gap = t - lastNewAt;
// The gap either side of a pause is not Resi's cadence — recording it // The gap either side of a pause is not Resi's cadence — recording it
// would inflate the estimate and make the next pause slow to notice. // would inflate the estimate and make the next pause slow to notice.
// But a pause is one gap; twice in a row is the cadence having changed.
const worst = gaps.length ? Math.max.apply(null, gaps) : 0; const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) { if (gap > 0 && gap < 60000) {
if (!worst || gap <= worst * 3 || rejected) {
gaps = gaps.concat(gap).slice(-8); gaps = gaps.concat(gap).slice(-8);
rejected = false;
} else {
rejected = true;
} }
} }
}
if (lastNewAt !== undefined) changes++;
offsets = offsets.concat({ at: t, off }); offsets = offsets.concat({ at: t, off });
lastMedia = mediaMs; lastMedia = mediaMs;
lastNewAt = t; lastNewAt = t;
@@ -118,7 +133,9 @@ function createPlayhead(options) {
// Only call it paused once the playhead has been still for clearly longer than // Only call it paused once the playhead has been still for clearly longer than
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a // Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
// countdown that freezes mid-service is the worse failure of the two. // countdown that freezes mid-service is the worse failure of the two.
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)); // Until a full gap has been measured the cadence is unknown; assume the slowest
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
const playing = base !== undefined && t - lastNewAt < pauseAfter; const playing = base !== undefined && t - lastNewAt < pauseAfter;
if (!playing) { if (!playing) {
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs }; return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
@@ -161,13 +178,43 @@ function createResiClient(options) {
return h; return h;
} }
/* A failed login used to be retried on the very next poll -- once a second, for
ever. Resi answers that with 429s, and since the retries never let up, the
account never got out of them. So a failure now holds off further attempts,
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
call in between fails at once with the same reason instead of signing in. */
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
let loginFailures = 0;
let loginBlockedUntil = 0;
let loginError = '';
async function login() { async function login() {
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, { if (now() < loginBlockedUntil) {
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', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ userName: opts.username, password: opts.password }), body: JSON.stringify({ userName: opts.username, password: opts.password }),
}); });
if (!res.ok) throw new Error(`Resi login failed (${res.status})`); } catch (err) {
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
throw new Error(loginError);
}
if (!res.ok) {
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
: `Resi login failed (${res.status})`;
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff(reason, retryAfter * 1000);
throw new Error(reason);
}
loginFailures = 0;
loginBlockedUntil = 0;
loginError = '';
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie()) const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []); || (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
const jar = {}; const jar = {};
@@ -179,24 +226,49 @@ function createResiClient(options) {
token = jar.livingas1 || ''; token = jar.livingas1 || '';
if (!token) throw new Error('Resi login returned no session cookie'); if (!token) throw new Error('Resi login returned no session cookie');
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; '); cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
loggedInAt = Date.now(); loggedInAt = now();
const me = await get('/api_v2.svc/users/me'); const me = await get('/api_v2.svc/users/me');
customerId = (me && me.customerId) || ''; customerId = (me && me.customerId) || '';
if (!customerId) throw new Error('This Resi account has no customerId'); if (!customerId) throw new Error('This Resi account has no customerId');
return me; return me;
} }
async function ensureSession() { function holdOff(reason, askedMs) {
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login(); loginFailures++;
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
loginError = reason;
} }
async function ensureSession() {
if (!token || now() - loggedInAt > 50 * 60 * 1000) await login();
}
/* The same hold-off covers every request, not just the login. A 401/403 used to
sign in again on the spot, every time: for a login that simply isn't allowed to
see a venue, that was a fresh sign-in on every poll. Now a refusal straight
after signing in is taken as what it is -- a permission -- and held off like a
failed login; only an older session is renewed. A 429 holds off too. */
async function get(path) { async function get(path) {
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() }); if (now() < loginBlockedUntil) {
if (res.status === 401 || res.status === 403) { const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
throw new Error(`${loginError} — retrying in ${wait}s`);
}
let res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) {
await login(); await login();
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() }); res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`); }
return retry.json(); if (res.status === 401 || res.status === 403) {
const where = path.includes('/venues') ? 'its venues'
: path.includes('/players') ? "this venue's decoders" : 'part of the account';
holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0);
throw new Error(loginError);
}
if (res.status === 429) {
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000);
throw new Error(loginError);
} }
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`); if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
return res.json(); return res.json();
@@ -216,7 +288,10 @@ function createResiClient(options) {
try { try {
return (await get(`${path}?_=${Date.now()}`)) || []; return (await get(`${path}?_=${Date.now()}`)) || [];
} catch (err) { } catch (err) {
if (!/\((4\d\d)\)/.test(String(err.message))) throw err; // Only an objection to the parameter itself; a refusal or a "slow down"
// (401/403/429) is about the login, and dropping it then lost fresh readings.
const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1];
if (!code || ['401', '403', '429'].includes(code)) throw err;
cacheBust = false; cacheBust = false;
} }
} }
+26
View File
@@ -197,6 +197,32 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
assert.strictEqual(surroundingCues([], 1000).next, null); assert.strictEqual(surroundingCues([], 1000).next, null);
}); });
/* ── starting partway through Resi's cycle ─────────────────────────────── */
// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a
// few seconds before a publish. The first "gap" it measured was 2s (9s after a
// reboot), and once that was the cadence every honest 30s gap was thrown out as a
// pause gap -- so playback read as paused 15s into every update, the countdown froze,
// and the next cue (and its name) stopped moving on.
for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) {
test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => {
const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 });
w.lastPublish = w.t - into;
const head = createPlayhead({ now: () => w.t });
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
let paused = 0;
for (let i = 0; i < 100; i++) if (!second().playing) paused++;
if (burst) {
w.media += 15 * 60000; // a seek, published at once...
w.lastPublish = w.t - w.nextCadence;
second(); second();
w.lastPublish = w.t - w.nextCadence; // ...and again 2s later
}
for (let i = 0; i < 300; i++) if (!second().playing) paused++;
assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`);
});
}
/* ── the matrix ────────────────────────────────────────────────────────── */ /* ── the matrix ────────────────────────────────────────────────────────── */
const argv = process.argv.slice(2).map(Number); const argv = process.argv.slice(2).map(Number);
+20 -4
View File
@@ -70,6 +70,7 @@ function emptyTimers(now) {
} }
let current = null; // the live handle, shared with routes() let current = null; // the live handle, shared with routes()
let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials
module.exports = { module.exports = {
init({ config, log }) { init({ config, log }) {
@@ -146,7 +147,9 @@ module.exports = {
elapsedMs: null, elapsedMs: null,
remainingMs, remainingMs,
targetMs: Math.max(1000, state.next.ms - from), targetMs: Math.max(1000, state.next.ms - from),
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' }, // The label already is the cue's name, and it is what the card is titled
// with; repeating it on the status line only crowded the card.
status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' },
detail: state.event || '', detail: state.event || '',
}); });
} }
@@ -271,6 +274,7 @@ module.exports = {
stateStreams, stateStreams,
timerStreams, timerStreams,
resi, resi,
credentialsKey: `${username}\n${password}`,
configured: Boolean(username && password), configured: Boolean(username && password),
stop() { stop() {
stopped = true; stopped = true;
@@ -332,9 +336,21 @@ module.exports = {
return json(res, 200, { options: [], note: 'Enter the Resi login first' }); return json(res, 200, { options: [], note: 'Enter the Resi login first' });
} }
try { try {
const client = createResiClient({ // The running module's client when it has these credentials, else one kept
username: String(config.username), password: String(config.password), // per login: a fresh client per request signed in on every admin page load,
}); // and its own hold-off never applied -- which kept Resi rate-limiting.
const username = String(config.username).trim();
const key = `${username}\n${config.password}`;
let client;
if (current && current.resi && current.credentialsKey === key) client = current.resi;
else {
if (!pickerClient || pickerClient.key !== key) {
pickerClient = { key, client: createResiClient({
username, password: String(config.password),
}) };
}
client = pickerClient.client;
}
const options = []; const options = [];
for (const v of await client.venues()) { for (const v of await client.venues()) {
for (const p of await client.players(v.uuid)) { for (const p of await client.players(v.uuid)) {
+93 -14
View File
@@ -18,8 +18,9 @@
const assert = require('assert'); const assert = require('assert');
let passed = 0; let passed = 0;
function test(name, fn) { // Awaited, so an async test finishes before the next one changes the world under it.
fn(); async function test(name, fn) {
await fn();
passed++; passed++;
console.log(` ok ${name}`); console.log(` ok ${name}`);
} }
@@ -101,7 +102,7 @@ function collect(route, req) {
const routes = server.routes({ config: {}, log: (m) => logged.push(m) }); const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
test('signs in once and finds the configured decoder', () => { await test('signs in once and finds the configured decoder', () => {
assert.strictEqual(world.logins, 1, 'should sign in exactly once'); assert.strictEqual(world.logins, 1, 'should sign in exactly once');
const state = handle.state; const state = handle.state;
assert.strictEqual(state.status, 'ok', state.message); assert.strictEqual(state.status, 'ok', state.message);
@@ -110,20 +111,20 @@ function collect(route, req) {
assert.strictEqual(state.cues.length, 3); assert.strictEqual(state.cues.length, 3);
}); });
test('works out the cue either side of the playhead', () => { await test('works out the cue either side of the playhead', () => {
const state = handle.state; const state = handle.state;
assert.strictEqual(state.next.name, 'Campus Host'); assert.strictEqual(state.next.name, 'Campus Host');
assert.strictEqual(state.prev.name, 'Welcome'); assert.strictEqual(state.prev.name, 'Welcome');
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`); assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
}); });
test('/state answers the same snapshot as the stream', () => { await test('/state answers the same snapshot as the stream', () => {
const res = collect(routes['GET /state']); const res = collect(routes['GET /state']);
assert.strictEqual(res.code, 200); assert.strictEqual(res.code, 200);
assert.deepStrictEqual(JSON.parse(res.body), handle.state); assert.deepStrictEqual(JSON.parse(res.body), handle.state);
}); });
test('/stream opens with an event so a new tile paints at once', () => { await test('/stream opens with an event so a new tile paints at once', () => {
const res = collect(routes['GET /stream']); const res = collect(routes['GET /stream']);
assert.strictEqual(res.code, 200); assert.strictEqual(res.code, 200);
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8'); assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
@@ -131,7 +132,7 @@ function collect(route, req) {
assert.ok(res.body.includes('event: state'), 'should send an initial state event'); assert.ok(res.body.includes('event: state'), 'should send an initial state event');
}); });
test('/timers offers the countdown and the playhead, shaped as the guide says', () => { await test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
const snap = JSON.parse(collect(routes['GET /timers']).body); const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.source, 'resi-cues'); assert.strictEqual(snap.source, 'resi-cues');
assert.strictEqual(snap.label, 'Resi'); assert.strictEqual(snap.label, 'Resi');
@@ -145,7 +146,7 @@ function collect(route, req) {
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`); assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
// the countdown runs from the cue before it, so a consumer can colour progress // the countdown runs from the cue before it, so a consumer can colour progress
assert.strictEqual(next.targetMs, 450000); assert.strictEqual(next.targetMs, 450000);
assert.strictEqual(next.status.text, 'Campus Host'); assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past'); assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
assert.strictEqual(playhead.id, 'playhead'); assert.strictEqual(playhead.id, 'playhead');
@@ -154,7 +155,7 @@ function collect(route, req) {
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000); assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
}); });
test('a cue fired at the host moves the countdown on the next poll', async () => { await test('a cue fired at the host moves the countdown on the next poll', async () => {
world.position = '00:12:40.000'; // jumped past Campus Host world.position = '00:12:40.000'; // jumped past Campus Host
tickFn(); tickFn();
await settle(); await settle();
@@ -162,10 +163,10 @@ function collect(route, req) {
assert.strictEqual(state.next.name, 'Message'); assert.strictEqual(state.next.name, 'Message');
assert.strictEqual(state.prev.name, 'Campus Host'); assert.strictEqual(state.prev.name, 'Campus Host');
const snap = JSON.parse(collect(routes['GET /timers']).body); const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.timers[0].label, 'Message'); assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue
}); });
test('a decoder with no event reports nothing playing rather than stale cues', async () => { await test('a decoder with no event reports nothing playing rather than stale cues', async () => {
world.eventId = ''; world.eventId = '';
tickFn(); tickFn();
await settle(); await settle();
@@ -174,13 +175,13 @@ function collect(route, req) {
assert.strictEqual(handle.state.event, ''); assert.strictEqual(handle.state.event, '');
}); });
test('health speaks about the upstream, not about itself', () => { await test('health speaks about the upstream, not about itself', () => {
const h = handle.health(); const h = handle.health();
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status); assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
assert.ok(h.message.length > 0, 'health should say something useful'); assert.ok(h.message.length > 0, 'health should say something useful');
}); });
test('an unconfigured module idles instead of hammering Resi', () => { await test('an unconfigured module idles instead of hammering Resi', () => {
const before = world.logins; const before = world.logins;
const idle = server.init({ config: {}, log: () => {} }); const idle = server.init({ config: {}, log: () => {} });
assert.strictEqual(world.logins, before, 'must not sign in with no credentials'); assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
@@ -189,12 +190,90 @@ function collect(route, req) {
idle.stop(); idle.stop();
}); });
test('stop() leaves nothing running', () => { await test('stop() leaves nothing running', () => {
handle.stop(); handle.stop();
const res = collect(routes['GET /state']); const res = collect(routes['GET /state']);
assert.strictEqual(JSON.parse(res.body).status, 'error'); assert.strictEqual(JSON.parse(res.body).status, 'error');
}); });
// Seen live at North Attleboro: a failed login was retried on every 1s poll, so
// Resi answered 429 and kept answering it, and the decoder picker stayed empty.
await test('a failed login holds off instead of retrying every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let limited = true;
const client = createResiClient({
username: 'a@b.c',
password: 'secret',
now: () => t,
fetch: async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
logins++;
if (limited) return reply({}, { ok: false, status: 429 });
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]);
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /rate-limiting/);
for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls
assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`);
assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden');
limited = false;
t += 5 * 60000; // past the longest hold-off
assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in');
const after = logins;
for (let i = 0; i < 30; i++) { t += 1000; await poll(); }
assert.strictEqual(logins, after, 'a good session is not signed in again');
});
await test('a rejected password says so', async () => {
const { createResiClient } = require('./resi');
const client = createResiClient({
username: 'a@b.c', password: 'wrong', now: () => 0,
fetch: async () => reply({}, { ok: false, status: 400 }),
});
await assert.rejects(client.venues(), /check the password/);
});
// Seen live: media@waters.church signs in fine but may not list venues (403). The
// client answered every 403 by signing in again -- a fresh login on every poll.
await test('a login that may not see venues is held off, not signed in every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let venueCalls = 0;
const client = createResiClient({
username: 'a@b.c', password: 'secret', now: () => t,
fetch: async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
logins++;
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); }
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /isn't allowed to see its venues \(403\)/);
for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls
// At most one renewal once the session is over a minute old (in case it really
// had expired), then the hold-off keeps growing -- not one sign-in per poll.
assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`);
assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`);
assert.match(await poll(), /ask a Resi admin/);
});
globalThis.setInterval = realSetInterval; globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval; globalThis.clearInterval = realClearInterval;
console.log(`\n${passed} tests passed`); console.log(`\n${passed} tests passed`);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.11.0", "version": "1.13.0",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+52
View File
@@ -648,3 +648,55 @@ details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
gap: 8px; gap: 8px;
margin-left: auto; margin-left: auto;
} }
/* an orderable multiselect (manifest `"orderable": true`): not a ticklist but
a list built on purpose — the picks, numbered, each with a nudge either way
and a way out, over a dropdown holding whatever is left to add. Their
position is the setting, so it has to be visible and movable. */
.multiselect-row.is-picked {
gap: 6px;
cursor: default;
padding: 4px 7px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
}
.multiselect-add {
width: 100%;
margin-top: 2px;
padding: 7px 9px;
font-size: 13px;
font-family: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
cursor: pointer;
}
.multiselect-add:focus { outline: none; border-color: var(--accent); }
.multiselect-add:disabled { color: var(--muted); cursor: default; }
.multiselect-rank {
flex: none;
min-width: 16px;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--accent);
}
.multiselect-row .switch-label { flex: 1; min-width: 0; }
.multiselect-move {
flex: none;
width: 20px;
height: 20px;
padding: 0;
line-height: 1;
font-size: 9px;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
}
.multiselect-move:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
.multiselect-move:disabled { opacity: 0.3; cursor: default; }
+133 -13
View File
@@ -333,15 +333,31 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
span.textContent = label; span.textContent = label;
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'multiselect'; list.className = 'multiselect';
const chosen = new Set((Array.isArray(value) ? value : []).map(String).filter(Boolean)); // Kept as an ORDER, not a set: with `orderable` the position is the
// value (a priority), and even without it the saved order is what the
// shell stores, so losing it here would quietly reshuffle a module's list.
let chosen = (Array.isArray(value) ? value : []).map(String).filter(Boolean);
const orderable = Boolean(spec.orderable);
const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt); const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt);
const fill = (options) => { const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
const syncChosen = () => {
for (const input of list.querySelectorAll('input')) { for (const input of list.querySelectorAll('input')) {
if (input.checked) chosen.add(input.value); const v = input.value;
else chosen.delete(input.value); const at = chosen.indexOf(v);
if (input.checked && at < 0) chosen.push(v); // newly on: lowest priority
else if (!input.checked && at >= 0) chosen.splice(at, 1);
} }
};
const move = (v, by) => {
const at = chosen.indexOf(v);
const to = at + by;
if (at < 0 || to < 0 || to >= chosen.length) return;
chosen.splice(to, 0, chosen.splice(at, 1)[0]);
};
const fill = (options) => {
syncChosen();
list.innerHTML = ''; list.innerHTML = '';
const missing = [...chosen].filter((v) => !options.some((opt) => optValue(opt) === v)); const missing = chosen.filter((v) => !options.some((opt) => optValue(opt) === v));
const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))]; const all = [...options, ...missing.map((v) => ({ value: v, label: `${v} (saved — not listed now)` }))];
for (const opt of all) { for (const opt of all) {
const row = document.createElement('div'); const row = document.createElement('div');
@@ -351,13 +367,13 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'checkbox'; input.type = 'checkbox';
input.value = optValue(opt); input.value = optValue(opt);
input.checked = chosen.has(input.value); input.checked = chosen.includes(input.value);
const track = document.createElement('span'); const track = document.createElement('span');
track.className = 'track'; track.className = 'track';
toggle.append(input, track); toggle.append(input, track);
const text = document.createElement('span'); const text = document.createElement('span');
text.className = 'switch-label'; text.className = 'switch-label';
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); text.textContent = optLabel(opt);
row.append(toggle, text); row.append(toggle, text);
row.addEventListener('click', (e) => { row.addEventListener('click', (e) => {
if (e.target === input) return; // the box toggles itself if (e.target === input) return; // the box toggles itself
@@ -373,21 +389,114 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
list.appendChild(none); list.appendChild(none);
} }
}; };
fill(Array.isArray(spec.options) ? spec.options : []); // A list built on purpose, in an order that means something: the picks,
// each numbered and movable, and a dropdown holding everything not picked
// yet. Switches cannot carry an order — and an account with forty service
// types is forty switches to read past before finding the two that matter.
const fillPicked = (options) => {
list.innerHTML = '';
const byValue = new Map(options.map((opt) => [optValue(opt), opt]));
chosen.forEach((v, i) => {
const opt = byValue.get(v);
const row = document.createElement('div');
row.className = 'multiselect-row is-picked';
const rank = document.createElement('span');
rank.className = 'multiselect-rank';
rank.textContent = String(i + 1);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = opt ? optLabel(opt) : `${v} (saved — not listed now)`;
const btn = (glyph, title, on, off) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'multiselect-move';
b.textContent = glyph;
b.title = title;
b.disabled = Boolean(off);
b.addEventListener('click', (e) => { e.preventDefault(); on(); });
return b;
};
row.append(
rank,
text,
btn('▲', 'Higher priority', () => { move(v, -1); changed(); }, i === 0),
btn('▼', 'Lower priority', () => { move(v, 1); changed(); }, i === chosen.length - 1),
btn('✕', 'Remove', () => { chosen.splice(chosen.indexOf(v), 1); changed(); }),
);
list.appendChild(row);
});
if (!chosen.length) {
const none = document.createElement('div');
none.className = 'multiselect-empty';
none.textContent = 'Nothing chosen yet.';
list.appendChild(none);
}
// The adder. Its own change bubbles to the field, which is what any
// dependent list listens for, so nothing extra is dispatched here.
const add = document.createElement('select');
add.className = 'multiselect-add';
const left = options.filter((opt) => !chosen.includes(optValue(opt)));
const head = document.createElement('option');
head.value = '';
head.textContent = left.length
? (spec.addLabel || 'Add one…')
: (options.length ? 'Everything is added' : 'Nothing to choose from yet');
add.appendChild(head);
add.disabled = !left.length;
const groups = new Map();
for (const opt of left) {
const o = document.createElement('option');
o.value = optValue(opt);
o.textContent = optLabel(opt);
const g = typeof opt === 'object' && opt.group ? String(opt.group) : '';
if (!g) { add.appendChild(o); continue; }
if (!groups.has(g)) {
const og = document.createElement('optgroup');
og.label = g;
groups.set(g, og);
add.appendChild(og);
}
groups.get(g).appendChild(o);
}
add.addEventListener('change', () => {
if (!add.value) return;
chosen.push(add.value); // added last: lowest priority
redraw();
});
list.appendChild(add);
};
// The options last drawn, so a reorder or a pick can redraw without asking
// the module for its list again.
let lastOptions = Array.isArray(spec.options) ? spec.options : [];
const redraw = () => (orderable ? fillPicked(lastOptions) : fill(lastOptions));
// Moving or removing changes the value without any control firing, so say so.
const changed = () => {
redraw();
list.dispatchEvent(new Event('change', { bubbles: true }));
};
redraw();
if (spec.optionsRoute && moduleId) { if (spec.optionsRoute && moduleId) {
refetch = (query) => { refetch = (query) => {
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : ''; const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`) return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
.then((res) => (res.ok ? res.json() : null)) .then((res) => (res.ok ? res.json() : null))
.then((body) => { .then((body) => {
if (Array.isArray(body?.options)) fill(body.options); if (Array.isArray(body?.options)) {
lastOptions = body.options;
redraw();
}
}) })
.catch(() => { /* keep the static/saved options */ }); .catch(() => { /* keep the static/saved options */ });
}; };
if (!spec.optionsDependsOn) refetch(); if (!spec.optionsDependsOn) refetch();
} }
field.append(span, list); field.append(span, list);
read = () => [...list.querySelectorAll('input:checked')].map((input) => input.value); read = () => {
// With `orderable` the list IS the value, in the order it is shown.
if (orderable) return [...chosen];
syncChosen();
return [...list.querySelectorAll('input:checked')].map((input) => input.value);
};
} else if (type === 'color') { } else if (type === 'color') {
// A swatch picker; the value is always a #rrggbb string. // A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field'); field.classList.add('color-field');
@@ -660,6 +769,7 @@ function buildModuleCard(mod) {
const readers = new Map(); const readers = new Map();
const controls = new Map(); // key → boolean/switch input (for showWhen) const controls = new Map(); // key → boolean/switch input (for showWhen)
const selects = new Map(); // key → select element (optionsDependsOn sources) const selects = new Map(); // key → select element (optionsDependsOn sources)
const sources = new Map(); // key → { el, value() } for optionsDependsOn, whatever the type
const dependents = []; // { field, when } to show/hide by a control's state const dependents = []; // { field, when } to show/hide by a control's state
const optionDeps = []; // { refetch, on } selects whose options follow another field const optionDeps = []; // { refetch, on } selects whose options follow another field
const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list) const reloaders = []; // re-run every optionsRoute fetch (after a save changed what the module can list)
@@ -717,6 +827,15 @@ function buildModuleCard(mod) {
readers.set(key, read); readers.set(key, read);
if (control) controls.set(key, control); if (control) controls.set(key, control);
if (selectEl) selects.set(key, selectEl); if (selectEl) selects.set(key, selectEl);
// Anything can be the source of an optionsDependsOn — a multiselect
// hands over its values joined, which is what a module's route reads.
sources.set(key, {
el: selectEl || control || field,
value: () => {
const v = read();
return Array.isArray(v) ? v.join(',') : (typeof v === 'boolean' ? String(v) : String(v ?? ''));
},
});
if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn }); if (refetch && spec?.optionsDependsOn) optionDeps.push({ refetch, on: spec.optionsDependsOn });
else if (refetch) reloaders.push(() => refetch()); else if (refetch) reloaders.push(() => refetch());
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen }); if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
@@ -741,9 +860,10 @@ function buildModuleCard(mod) {
// value as a query parameter, now and whenever K changes (a plan list // value as a query parameter, now and whenever K changes (a plan list
// that follows the chosen service type). // that follows the chosen service type).
for (const dep of optionDeps) { for (const dep of optionDeps) {
const src = selects.get(dep.on) || controls.get(dep.on); const src = sources.get(dep.on);
const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) }); const run = () => dep.refetch({ [dep.on]: src ? src.value() : '' });
if (src) src.addEventListener('change', run); // A multiselect's switches are inside the field, so listen where they live.
if (src) src.el.addEventListener('change', run);
reloaders.push(run); reloaders.push(run);
run(); run();
} }
+93 -4
View File
@@ -188,6 +188,10 @@ function menuItem(label, onClick, { sub = '', danger = false, disabled = false,
/* ── module registry ────────────────────────────────────────────────── */ /* ── module registry ────────────────────────────────────────────────── */
let registry = new Map(); // module id -> manifest from GET /api/modules let registry = new Map(); // module id -> manifest from GET /api/modules
/* The installed modules that are not running — disabled, or needing a newer
shell — id -> { id, name, reason }, from the same GET. Read for one thing:
to say why a tile one of them left in the layout is not drawn. */
let inactiveModules = new Map();
/** fetch that gives up after `ms`. A request the browser has queued behind /** fetch that gives up after `ms`. A request the browser has queued behind
held connections would otherwise pend forever, and so would anything held connections would otherwise pend forever, and so would anything
@@ -208,6 +212,7 @@ async function loadRegistry() {
if (!res.ok) throw new Error('modules ' + res.status); if (!res.ok) throw new Error('modules ' + res.status);
const body = await res.json(); const body = await res.json();
registry = new Map((body.modules || []).map((m) => [m.id, m])); registry = new Map((body.modules || []).map((m) => [m.id, m]));
inactiveModules = new Map((body.inactive || []).map((m) => [m.id, m]));
return true; return true;
} catch { } catch {
return false; return false;
@@ -487,7 +492,9 @@ const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true
registry offers them again: a Service timer waits for PCO Plan, a registry offers them again: a Service timer waits for PCO Plan, a
transcript for its channel. Never the module's default in their place — transcript for its channel. Never the module's default in their place —
a wall of "All timers" where five different timers were is worse than a wall of "All timers" where five different timers were is worse than
five gaps that fill themselves in. */ five gaps that fill themselves in. For the one that is not coming back
(a module stopped offering that kind of tile) the Layouts menu lists them
under Not shown, each with a ✕ — the only way to let its cells go. */
function tileIsOffered(tile) { function tileIsOffered(tile) {
const man = registry.get(tile.module); const man = registry.get(tile.module);
@@ -521,6 +528,7 @@ function reconcileOfferedTiles() {
if (changed) rebuildDividers(); if (changed) rebuildDividers();
scheduleOfferedRecheck(); scheduleOfferedRecheck();
refreshEmptyState(); // the registry may have gone from nothing to something refreshEmptyState(); // the registry may have gone from nothing to something
repaintUnshown(); // a Not shown row goes the moment its tile is drawn again
} }
/* A module's entry list can change without anyone saving admin config: PCO /* A module's entry list can change without anyone saving admin config: PCO
@@ -540,10 +548,90 @@ function scheduleOfferedRecheck() {
}, OFFERED_RECHECK_MS); }, OFFERED_RECHECK_MS);
} }
/** The tiles a layout holds that the board is not showing, each with what
to call it and, in a few words, why it is not drawn. */
function unshownTiles() {
return tiles.filter((t) => !tileEls.has(t.id)).map((tile) => {
const man = registry.get(tile.module);
if (man) {
const title = tile.title || (tile.variant ? `${man.name} · ${tile.variant}` : man.name);
return { tile, title, reason: 'not offered right now' };
}
const off = inactiveModules.get(tile.module);
if (off) {
const reason = off.reason === 'incompatible' ? 'module needs a newer ProdDash' : 'module disabled';
return { tile, title: tile.title || off.name || tile.module, reason };
}
return { tile, title: tile.title || tile.module, reason: 'module not installed' };
});
}
/** The tiles a layout holds that the board is not showing, by name. */ /** The tiles a layout holds that the board is not showing, by name. */
function unshownTileNames() { function unshownTileNames() {
return tiles.filter((t) => !tileEls.has(t.id)) return unshownTiles().map((u) => u.title);
.map((t) => t.title || registry.get(t.module)?.name || t.module); }
/* Repaints the Layouts menu's Not shown section while the menu is open —
set by unshownSection(), called whenever tiles become offered or stop
being, so the list never lags the board behind it. */
let repaintUnshown = () => {};
/** The Layouts menu's Not shown section: one row per tile the layout holds
that the board is not drawing — its name, why in a word or two, and a ✕
that removes it — present only while there is one. A tile that waits for
its module (a Service timer before PCO Plan is up) is listed but can stay;
this exists for the one that will never be back, which would otherwise
hold its cells with no control anywhere to let them go. */
function unshownSection() {
const wrap = document.createElement('div');
wrap.className = 'menu-unshown';
const signature = (list) => list.map((u) => `${u.tile.id}|${u.reason}`).join('\n');
let painted = '';
const paint = () => {
wrap.innerHTML = '';
const list = unshownTiles();
painted = signature(list);
wrap.hidden = !list.length;
if (!list.length) return;
const divider = document.createElement('div');
divider.className = 'menu-divider';
const title = document.createElement('div');
title.className = 'menu-title';
title.textContent = 'Not shown';
wrap.append(divider, title);
for (const { tile, title: name, reason } of list) {
const row = document.createElement('div');
row.className = 'menu-item menu-unshown-row';
const label = document.createElement('span');
label.textContent = name;
const sub = document.createElement('span');
sub.className = 'sub';
sub.textContent = reason;
label.appendChild(sub);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'menu-x';
remove.setAttribute('data-tip', 'Remove');
remove.setAttribute('aria-label', `Remove ${name}`);
remove.innerHTML = CLOSE_SVG;
remove.addEventListener('click', (e) => {
// The row goes with the tile, so the click must not reach the
// document as one from outside the menu — the menu stays open
// to show the next row (or none), as the stepper and Alerts do.
e.stopPropagation();
removeTile(tile.id); // saves the layout and frees the cells, as a drawn tile's ✕ does
paint();
});
row.append(label, remove);
wrap.appendChild(row);
}
};
paint();
// Only when something changed: the registry is re-read every few seconds
// while a tile is folded away, and rows rebuilt under the pointer for
// nothing would lose the tooltip (and the click) the operator was on.
repaintUnshown = () => { if (wrap.isConnected && signature(unshownTiles()) !== painted) paint(); };
return wrap;
} }
function buildTile(tile) { function buildTile(tile) {
@@ -2253,6 +2341,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
// how one is arranged. // how one is arranged.
menu.appendChild(gridStepper()); menu.appendChild(gridStepper());
menu.appendChild(alertsFold()); menu.appendChild(alertsFold());
menu.appendChild(unshownSection()); // only while a tile the layout holds is not drawn
const stepperDivider = document.createElement('div'); const stepperDivider = document.createElement('div');
stepperDivider.className = 'menu-divider'; stepperDivider.className = 'menu-divider';
menu.appendChild(stepperDivider); menu.appendChild(stepperDivider);
@@ -2344,7 +2433,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`; const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : ''; const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
const note = unshown.length const note = unshown.length
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now` ? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now; the Layouts menu can remove ${unshown.length === 1 ? 'it' : 'them'}`
: ''; : '';
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, { toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
ms: unshown.length ? 10000 : 7000, ms: unshown.length ? 10000 : 7000,
+27
View File
@@ -368,6 +368,33 @@ body[data-alert="critical"] .tile.is-tinted {
text-transform: uppercase; text-transform: uppercase;
} }
/* the Layouts menu's Not shown rows: a tile the layout holds but the board
is not drawing, why in a word or two, and the ✕ that removes it */
.menu-unshown[hidden] { display: none; }
.menu-unshown-row { cursor: default; }
.menu-unshown-row:hover { background: none; }
.menu-unshown-row > span:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-x {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: none;
border: none;
border-radius: 5px;
color: var(--muted);
cursor: pointer;
}
.menu-x:hover { background: var(--border); color: var(--danger); }
.menu-x svg { width: 12px; height: 12px; display: block; }
/* ── grid ──────────────────────────────────────────────────────────── */ /* ── grid ──────────────────────────────────────────────────────────── */
#gridwrap { #gridwrap {
+10 -1
View File
@@ -2336,8 +2336,17 @@ function handleRequest(req, res) {
/* — shell API — */ /* — shell API — */
if (urlPath === '/api/modules' && req.method === 'GET') { if (urlPath === '/api/modules' && req.method === 'GET') {
// Beside what dashboards may load: the installed modules that are not
// running, by id and name only, so a tile one of them left in a layout
// can say why it is not drawn ("module disabled") instead of guessing at
// "not installed". Nothing of their config travels with it.
const inactive = [...manifests.keys()].filter((id) => !isActive(id)).map((id) => ({
id,
name: String(manifests.get(id).name || id),
reason: isEnabled(id) ? 'incompatible' : 'disabled',
}));
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest)) Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
.then((list) => sendJson(res, 200, { modules: list })) .then((list) => sendJson(res, 200, { modules: list, inactive }))
.catch((err) => { .catch((err) => {
console.error('[proddash] /api/modules failed:', err); console.error('[proddash] /api/modules failed:', err);
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' }); if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
+19
View File
@@ -8,6 +8,9 @@
* Serves JSON:API shapes matching api.planningcenteronline.com/services/v2: * Serves JSON:API shapes matching api.planningcenteronline.com/services/v2:
* GET /services/v2/folders nested folders (parent rel) * GET /services/v2/folders nested folders (parent rel)
* GET /services/v2/folders/{id} * GET /services/v2/folders/{id}
* Run: node tools/pco-mock.js [port] [--types N] (--types pads the service
* type list out to N, for trying a picker against an account that has dozens.)
*
* GET /services/v2/service_types every service type, with its * GET /services/v2/service_types every service type, with its
* parent folder relationship * parent folder relationship
* GET /services/v2/service_types/{id} * GET /services/v2/service_types/{id}
@@ -49,6 +52,10 @@
const http = require('http'); const http = require('http');
const PORT = Number.parseInt(process.argv[2] || '24700', 10); const PORT = Number.parseInt(process.argv[2] || '24700', 10);
// --types N pads the list out to N with planless filler, for accounts that
// carry dozens of them: enough to see whether a picker still works at that size.
const TYPE_COUNT = Number.parseInt((process.argv.find((a) => a.startsWith('--types')) || '').split('=')[1]
|| (process.argv[process.argv.indexOf('--types') + 1] || ''), 10);
const bootedAt = Date.now(); const bootedAt = Date.now();
/* ── data ───────────────────────────────────────────────────────────── */ /* ── data ───────────────────────────────────────────────────────────── */
@@ -68,6 +75,18 @@ const SERVICE_TYPES = [
{ id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' }, { id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' },
]; ];
if (Number.isFinite(TYPE_COUNT) && TYPE_COUNT > SERVICE_TYPES.length) {
FOLDERS.push({ id: '300', name: 'Other Campuses', parentId: '' });
for (let i = SERVICE_TYPES.length; i < TYPE_COUNT; i += 1) {
SERVICE_TYPES.push({
id: String(2000 + i),
name: `Campus ${String.fromCharCode(65 + (i % 26))}${i >= 26 + 5 ? Math.floor(i / 26) : ''} Service`,
frequency: 'Every week',
parentId: i % 3 === 0 ? '300' : (i % 3 === 1 ? '110' : ''),
});
}
}
function iso(ms) { function iso(ms) {
return new Date(ms).toISOString(); return new Date(ms).toISOString();
} }