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:
co-authored by
Claude Opus 5
parent
f9cd292693
commit
7bb1131378
@@ -648,3 +648,55 @@ details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
|
||||
gap: 8px;
|
||||
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;
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user