Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34a6def839 | ||
|
|
c9e03398a6 | ||
|
|
7bb1131378 | ||
|
|
f9cd292693 |
@@ -403,10 +403,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.
|
||||||
|
|||||||
+17
-1
@@ -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":
|
||||||
|
|||||||
+38
-20
@@ -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();
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.11.0",
|
"version": "1.12.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1121,6 +1121,11 @@ function parseTar(buf) {
|
|||||||
while (off + 512 <= buf.length) {
|
while (off + 512 <= buf.length) {
|
||||||
const h = buf.subarray(off, off + 512);
|
const h = buf.subarray(off, off + 512);
|
||||||
if (h.every((b) => b === 0)) break; // end-of-archive blocks
|
if (h.every((b) => b === 0)) break; // end-of-archive blocks
|
||||||
|
// The mode matters: modules ship prebuilt helpers under bin/, and writing
|
||||||
|
// one out without its execute bits leaves spawn() with EACCES and the
|
||||||
|
// feature silently dead. Permission bits only — setuid/sticky from an
|
||||||
|
// archive are not wanted here.
|
||||||
|
const mode = (parseInt(cstr(h, 100, 8).trim() || '0', 8) & 0o777) || 0o644;
|
||||||
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
|
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
|
||||||
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
|
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
|
||||||
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
|
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
|
||||||
@@ -1136,7 +1141,7 @@ function parseTar(buf) {
|
|||||||
}
|
}
|
||||||
const name = pendingName || shortName;
|
const name = pendingName || shortName;
|
||||||
pendingName = null;
|
pendingName = null;
|
||||||
if (type === '0') files.set(name, Buffer.from(data));
|
if (type === '0') files.set(name, { data: Buffer.from(data), mode });
|
||||||
}
|
}
|
||||||
return { files, comment };
|
return { files, comment };
|
||||||
}
|
}
|
||||||
@@ -1165,9 +1170,9 @@ async function fetchArchive() {
|
|||||||
}
|
}
|
||||||
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
||||||
const stripped = new Map(); // drop the archive's single top-level folder
|
const stripped = new Map(); // drop the archive's single top-level folder
|
||||||
for (const [p, data] of files) {
|
for (const [p, entry] of files) {
|
||||||
const i = p.indexOf('/');
|
const i = p.indexOf('/');
|
||||||
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data);
|
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), entry);
|
||||||
}
|
}
|
||||||
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
|
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
|
||||||
}
|
}
|
||||||
@@ -1182,17 +1187,17 @@ async function refreshRemote(force = false) {
|
|||||||
const job = (async () => {
|
const job = (async () => {
|
||||||
try {
|
try {
|
||||||
const { files, sha } = await fetchArchive();
|
const { files, sha } = await fetchArchive();
|
||||||
const pkg = safeJson(files.get('package.json'));
|
const pkg = safeJson(files.get('package.json')?.data);
|
||||||
const modules = new Map();
|
const modules = new Map();
|
||||||
for (const [p, data] of files) {
|
for (const [p, file] of files) {
|
||||||
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
|
const m = p.match(/^modules\/([^/]+)\/(.+)$/);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
|
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
|
||||||
entry.files.set(m[2], data);
|
entry.files.set(m[2], file);
|
||||||
modules.set(m[1], entry);
|
modules.set(m[1], entry);
|
||||||
}
|
}
|
||||||
for (const [id, entry] of modules) {
|
for (const [id, entry] of modules) {
|
||||||
const man = safeJson(entry.files.get('module.json'));
|
const man = safeJson(entry.files.get('module.json')?.data);
|
||||||
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
|
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
|
||||||
modules.delete(id);
|
modules.delete(id);
|
||||||
continue;
|
continue;
|
||||||
@@ -1322,12 +1327,14 @@ async function installModule(id) {
|
|||||||
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
const shipped = new Set();
|
const shipped = new Set();
|
||||||
for (const [rel, data] of remoteEntry.files) {
|
for (const [rel, { data, mode }] of remoteEntry.files) {
|
||||||
if (!safeRelPath(rel)) continue;
|
if (!safeRelPath(rel)) continue;
|
||||||
shipped.add(rel);
|
shipped.add(rel);
|
||||||
const target = path.join(staging, rel);
|
const target = path.join(staging, rel);
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
fs.writeFileSync(target, data);
|
// The tarball's mode, or a shipped bin/ helper lands 0644 and spawns
|
||||||
|
// EACCES (what broke LTC and SPL on the 1.10.2 rollout, 2026-10-04).
|
||||||
|
fs.writeFileSync(target, data, { mode });
|
||||||
}
|
}
|
||||||
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
const dest = path.join(INSTALLED_MODULES_DIR, id);
|
||||||
// Per-machine executables — a capture tool compiled here, or copied in
|
// Per-machine executables — a capture tool compiled here, or copied in
|
||||||
@@ -1439,11 +1446,11 @@ async function updateShell() {
|
|||||||
} else {
|
} else {
|
||||||
const staging = path.join(ROOT, '.proddash-update');
|
const staging = path.join(ROOT, '.proddash-update');
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
for (const [rel, data] of remote.files) {
|
for (const [rel, { data, mode }] of remote.files) {
|
||||||
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
|
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
|
||||||
const target = path.join(staging, rel);
|
const target = path.join(staging, rel);
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
fs.writeFileSync(target, data);
|
fs.writeFileSync(target, data, { mode });
|
||||||
}
|
}
|
||||||
const newServer = path.join(staging, 'server.js');
|
const newServer = path.join(staging, 'server.js');
|
||||||
if (!fs.existsSync(newServer)) {
|
if (!fs.existsSync(newServer)) {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -12,7 +12,12 @@
|
|||||||
* 2. a shell update from an archive that carries modules/ never writes
|
* 2. a shell update from an archive that carries modules/ never writes
|
||||||
* them into the app folder — they show up in the catalog instead;
|
* them into the app folder — they show up in the catalog instead;
|
||||||
* 3. a fresh install lists everything the repo offers, and installing
|
* 3. a fresh install lists everything the repo offers, and installing
|
||||||
* several at once lands them in the data directory and mounts them.
|
* several at once lands them in the data directory and mounts them;
|
||||||
|
* 4. a module downloaded from the catalog keeps the tarball's modes — a
|
||||||
|
* shipped bin/ helper is still executable (0644 means spawn EACCES,
|
||||||
|
* which is what broke LTC and SPL on the 1.10.2 rollout) — while
|
||||||
|
* per-machine executables the download does not include are still
|
||||||
|
* carried over from the copy being replaced.
|
||||||
*
|
*
|
||||||
* node tools/unbundled.test.js
|
* node tools/unbundled.test.js
|
||||||
*
|
*
|
||||||
@@ -70,6 +75,11 @@ function buildArchive(dir) {
|
|||||||
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
|
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
|
||||||
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
||||||
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
|
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
|
||||||
|
// The repo ships this one prebuilt (the way Timers and SPL ship their
|
||||||
|
// capture helpers): it must come out of the download still executable.
|
||||||
|
fs.mkdirSync(path.join(top, 'modules', 'demo', 'bin'));
|
||||||
|
fs.writeFileSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), '#!/bin/sh\necho capture\n');
|
||||||
|
fs.chmodSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), 0o755);
|
||||||
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
|
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
|
||||||
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
|
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
|
||||||
const out = path.join(dir, 'main.tar.gz');
|
const out = path.join(dir, 'main.tar.gz');
|
||||||
@@ -188,6 +198,17 @@ function buildPackagedApp(app) {
|
|||||||
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
|
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
|
||||||
assert.equal(empty.status, 400, 'nothing to install is said');
|
assert.equal(empty.status, 400, 'nothing to install is said');
|
||||||
|
|
||||||
|
// ── 4. a download keeps executable modes, and carry-over still works ──
|
||||||
|
const upd = await api('/api/admin/modules/demo/install', { method: 'POST', body: '{}' });
|
||||||
|
assert.equal(upd.status, 200, 'demo update: ' + JSON.stringify(upd.body));
|
||||||
|
assert.equal(upd.body.version, '2.0.0', 'demo was updated from the repo');
|
||||||
|
const shipped = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-capture');
|
||||||
|
assert.ok(fs.existsSync(shipped), 'the downloaded helper is there');
|
||||||
|
assert.ok(fs.statSync(shipped).mode & 0o111, 'and the tarball\'s execute bit survived the install');
|
||||||
|
const carried = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-helper');
|
||||||
|
assert.ok(fs.existsSync(carried), 'the per-machine executable the download lacks was carried over');
|
||||||
|
assert.ok(fs.statSync(carried).mode & 0o111, 'still executable');
|
||||||
|
|
||||||
console.log('unbundled shell: every check passed');
|
console.log('unbundled shell: every check passed');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed = true;
|
failed = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user