PCO Plan 1.10.0: follow several service types in priority order, and read any of them from any dashboard

An account runs more than one service type — a Sunday morning, a Wednesday
night, a youth service, a second campus — and until now that meant one
ProdDash per type. The module now follows as many as are configured.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-06 14:32:19 -04:00
co-authored by Claude Opus 5
parent f9cd292693
commit 7bb1131378
13 changed files with 800 additions and 131 deletions
+25 -13
View File
@@ -13,11 +13,15 @@ const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
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 }) {
let state = null;
let feedOffline = false;
let stream = null;
let lastKey = '';
let view = viewOf(null);
let offViewed = null;
root.innerHTML = `
<div class="tm-wrap">
@@ -39,6 +43,8 @@ export default function createTeams({ root, moduleApi }) {
/* ── title-bar controls ─────────────────────────────────────────── */
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
const refreshBtn = moduleApi.header.addButton({
icon: REFRESH_SVG,
title: 'Re-read from Planning Center',
@@ -92,7 +98,7 @@ export default function createTeams({ root, moduleApi }) {
/* ── which teams and people to show ─────────────────────────────── */
function visibleTeams() {
const teams = Array.isArray(state?.teams) ? state.teams : [];
const teams = Array.isArray(view.teams) ? view.teams : [];
const wantTeams = chosen('teams');
const out = [];
for (const team of teams) {
@@ -118,8 +124,8 @@ export default function createTeams({ root, moduleApi }) {
if (!state.reachable) {
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
}
if (state.teamsError) return moduleApi.setStatus('error', `Team list: ${state.teamsError}`);
const people = (state.teams || []).reduce((n, t) => n + t.members.length, 0);
if (view.teamsError) return moduleApi.setStatus('error', `Team list: ${view.teamsError}`);
const people = (view.teams || []).reduce((n, t) => n + t.members.length, 0);
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. */
function templateValues() {
const plan = state?.plan || {};
const plan = view.plan || {};
const teams = visibleTeams();
const people = teams.reduce((n, t) => n + t.members.length, 0);
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
return {
series: plan.seriesTitle || '',
part: plan.title || '',
servicetype: state?.serviceType?.name || '',
servicetype: view.name || '',
date: plan.dates || '',
shortdate: plan.shortDates || plan.dates || '',
time: plan.serviceStartsAt
@@ -171,11 +177,11 @@ export default function createTeams({ root, moduleApi }) {
}
function renderHead() {
const show = on('showTopBar') && state?.plan;
const show = on('showTopBar') && view.plan;
headEl.hidden = !show;
if (!show) return;
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.hidden = !subEl.textContent;
}
@@ -199,19 +205,19 @@ export default function createTeams({ root, moduleApi }) {
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 || '' };
}
if (!state.plan) {
if (!view.plan) {
return {
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 {
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) {
@@ -372,7 +378,7 @@ export default function createTeams({ root, moduleApi }) {
// Rebuild only when the roster or the settings actually changed: the
// avatars are <img>s and a needless rebuild makes them flicker.
const teams = visibleTeams();
const key = JSON.stringify([teams, cfg()]);
const key = JSON.stringify([view.id, teams, cfg()]);
if (key === lastKey) return;
lastKey = key;
bodyEl.innerHTML = '';
@@ -380,7 +386,10 @@ export default function createTeams({ root, moduleApi }) {
}
function render() {
view = viewOf(state);
switcher.sync();
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('header-gradient', on('headerGradient'));
renderStatus();
@@ -421,9 +430,12 @@ export default function createTeams({ root, moduleApi }) {
.then((body) => { if (body && !state) accept(body); })
.catch(() => { /* the stream will cover it */ });
connect();
offViewed = onViewedType(() => { lastKey = ''; render(); });
render();
},
stop() {
offViewed?.();
offViewed = null;
stream?.close();
stream = null;
root.innerHTML = '';