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
@@ -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 || ''));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user