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
+133 -13
View File
@@ -333,15 +333,31 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
span.textContent = label;
const list = document.createElement('div');
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 fill = (options) => {
const optLabel = (opt) => String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
const syncChosen = () => {
for (const input of list.querySelectorAll('input')) {
if (input.checked) chosen.add(input.value);
else chosen.delete(input.value);
const v = 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 = '';
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)` }))];
for (const opt of all) {
const row = document.createElement('div');
@@ -351,13 +367,13 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
const input = document.createElement('input');
input.type = 'checkbox';
input.value = optValue(opt);
input.checked = chosen.has(input.value);
input.checked = chosen.includes(input.value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
text.textContent = optLabel(opt);
row.append(toggle, text);
row.addEventListener('click', (e) => {
if (e.target === input) return; // the box toggles itself
@@ -373,21 +389,114 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
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) {
refetch = (query) => {
const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`)
.then((res) => (res.ok ? res.json() : null))
.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 */ });
};
if (!spec.optionsDependsOn) refetch();
}
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') {
// A swatch picker; the value is always a #rrggbb string.
field.classList.add('color-field');
@@ -660,6 +769,7 @@ function buildModuleCard(mod) {
const readers = new Map();
const controls = new Map(); // key → boolean/switch input (for showWhen)
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 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)
@@ -717,6 +827,15 @@ function buildModuleCard(mod) {
readers.set(key, read);
if (control) controls.set(key, control);
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 });
else if (refetch) reloaders.push(() => refetch());
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
// that follows the chosen service type).
for (const dep of optionDeps) {
const src = selects.get(dep.on) || controls.get(dep.on);
const run = () => dep.refetch({ [dep.on]: !src ? '' : (src.type === 'checkbox' ? String(src.checked) : src.value) });
if (src) src.addEventListener('change', run);
const src = sources.get(dep.on);
const run = () => dep.refetch({ [dep.on]: src ? src.value() : '' });
// A multiselect's switches are inside the field, so listen where they live.
if (src) src.el.addEventListener('change', run);
reloaders.push(run);
run();
}