diff --git a/README.md b/README.md index ff45484..2fe2a0f 100644 --- a/README.md +++ b/README.md @@ -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 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 - config is the connection: Planning - Center credentials (Personal Access Token app ID + secret, or an OAuth - token), the service type (a list grouped by your Planning Center folders) - and an optional pinned plan. Everything about the display is per tile + config is the connection: Planning Center credentials (Personal Access + Token app ID + secret, or an OAuth token), the service types to follow (a + list grouped by your Planning Center folders) and an optional pinned plan. + **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 `{series} • {part} | {serviceType} • {date}`), colors, text size, and a show/hide checklist for every detail. diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 773c054..1d53ab3 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -222,7 +222,9 @@ Both schemas map field names to specs: "help": "One line shown under the control", // optional, admin config only "options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only "optionsRoute": "/devices", // "select" only, admin config only - "optionsDependsOn": "" // with optionsRoute; see below + "optionsDependsOn": "", // 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 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). +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 — the popover GETs `/api/modules/` 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 clicking the variables the module offers into it: `"variables": [ { "value": diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index 685bc75..289d20c 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -14,6 +14,7 @@ import createTeams from "./teams-client.js"; import createKey from "./key-client.js"; +import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js"; import { prettyKey } from "./key-notation.js"; const REFRESH_SVG = ''; @@ -38,6 +39,10 @@ function createPlan({ root, moduleApi }) { let lastCurrentId = null; let lastListKey = ''; 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; root.innerHTML = ` @@ -89,6 +94,7 @@ function createPlan({ root, moduleApi }) { moduleApi.saveInstanceSettings({ textSize: Math.min(40, Math.max(9, size + delta)) }); applyPrefs(); } + const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state }); moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller 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 ────────────────────────────────────────── */ function visibleItems() { - const items = Array.isArray(state?.items) ? state.items : []; + const items = Array.isArray(view.items) ? view.items : []; return items.filter((it) => { if (it.itemType === 'header' && !on('showHeaders')) 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. */ function templateValues() { - const plan = state?.plan || {}; + const plan = view.plan || {}; const items = state?.items || []; const planned = plannedLength(); return { series: on('showSeries') ? plan.seriesTitle || '' : '', part: 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 || '' : '', shortdate: on('showDate') ? plan.shortDates || plan.dates || '' : '', time: on('showServiceTime') && plan.serviceStartsAt @@ -239,12 +245,12 @@ function createPlan({ root, moduleApi }) { } function renderHead() { - const plan = state?.plan; + const plan = view.plan; const show = on('showTopBar') && plan; headEl.hidden = !show; if (!show) return; 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.hidden = !planSubEl.textContent; renderClock(); @@ -258,9 +264,12 @@ function createPlan({ root, moduleApi }) { * time once it has ended. */ function renderClock() { - const plan = state?.plan; - const live = state?.live || {}; - const svc = state?.service || {}; + const plan = view.plan; + const live = view.live ? (state?.live || {}) : {}; + // 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 = ''; clockValueEl.textContent = ''; clockValueEl.className = 'pp-clock-value'; @@ -272,7 +281,7 @@ function createPlan({ root, moduleApi }) { clockValueEl.textContent = fmtDur((now - startedAt) / 1000); if (live.following) { // 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 actual = done.reduce((sum, it) => sum + (live.history[it.id].endedAt - live.history[it.id].startedAt) / 1000, 0); const drift = actual - planned; @@ -321,13 +330,13 @@ function createPlan({ 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 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).' }; @@ -483,7 +492,7 @@ function createPlan({ root, moduleApi }) { // Rebuild rows only when the plan content or the display settings // 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) { lastListKey = key; listEl.innerHTML = ''; @@ -523,6 +532,7 @@ function createPlan({ root, moduleApi }) { /** The row AbleSet's song belongs to — only ever the current one. */ function absRow() { 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.itemId !== lastCurrentId) return 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. */ 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 currentId = live.following ? live.currentItemId || '' : ''; 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. const doneIds = new Set(); if (currentId) { - for (const it of state.items) { + for (const it of view.items) { if (it.id === currentId) break; if (it.itemType !== 'header') doneIds.add(it.id); } @@ -676,8 +688,8 @@ function createPlan({ root, moduleApi }) { /* ── footer: what ProPresenter is doing ─────────────────────────── */ function renderFoot() { - const live = state?.live; - const show = on('showLiveFooter') && live?.following && state?.plan; + const live = view.live ? state?.live : null; + const show = on('showLiveFooter') && live?.following && view.plan; footEl.hidden = !show; footEl.className = 'pp-foot'; if (!show) return; @@ -713,7 +725,10 @@ function createPlan({ root, moduleApi }) { /* ── render + ticking ───────────────────────────────────────────── */ 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-types', Boolean(cfg().tintByType)); wrap.classList.toggle('header-gradient', cfg().headerGradient !== false); const color = (value, fallback) => { @@ -744,9 +759,9 @@ function createPlan({ root, moduleApi }) { function tick() { renderAbleSet(); - if (!state?.plan) return; + if (!view.plan) return; renderClock(); - if (state.live?.following && on('showItemRuntime')) renderLiveMarks(); + if (view.live && state.live?.following && on('showItemRuntime')) renderLiveMarks(); } /* ── live feed ──────────────────────────────────────────────────── */ @@ -805,11 +820,14 @@ function createPlan({ root, moduleApi }) { .then((body) => { if (body && !state) accept(body); }) .catch(() => { /* the stream will cover it */ }); connect(); + offViewed = onViewedType(() => { lastListKey = ''; render(); }); listEl.addEventListener('wheel', onUserScroll, { passive: true }); listEl.addEventListener('touchmove', onUserScroll, { passive: true }); ticker = setInterval(tick, 1000); }, stop() { + offViewed?.(); + offViewed = null; clearInterval(ticker); ticker = null; stream?.close(); diff --git a/modules/pco-plan/key-client.js b/modules/pco-plan/key-client.js index 1297227..1e64965 100644 --- a/modules/pco-plan/key-client.js +++ b/modules/pco-plan/key-client.js @@ -5,11 +5,14 @@ which item is current comes from the ProPresenter module, as ever. */ import { parseKey, prettyKey } from "./key-notation.js"; +import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js"; export default function createKey({ root, moduleApi }) { let state = null; let stream = null; let feedOffline = false; + let view = viewOf(null); + let offViewed = null; root.innerHTML = `
@@ -23,17 +26,21 @@ export default function createKey({ root, moduleApi }) { const nameEl = root.querySelector('.pk-name'); const nextEl = root.querySelector('.pk-next'); + const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state }); + const cfg = () => moduleApi.instanceSettings || {}; const on = (key) => cfg()[key] !== false; // unset = shown /* ── the item on screen, and the one after it ───────────────────── */ function items() { - return Array.isArray(state?.items) ? state.items : []; + return Array.isArray(view.items) ? view.items : []; } 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; } @@ -63,6 +70,7 @@ export default function createKey({ root, moduleApi }) { return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…'); } 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'); // 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. @@ -101,7 +109,10 @@ export default function createKey({ 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); const live = state?.live; wrap.classList.toggle('is-stale', Boolean(live?.following && (!live.available || !live.ppReachable))); const color = String(cfg().keyColor || '').trim(); @@ -120,7 +131,9 @@ export default function createKey({ root, moduleApi }) { } if (!item) { 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 = ''; return; } @@ -167,9 +180,12 @@ export default function createKey({ root, moduleApi }) { .then((body) => { if (body && !state) accept(body); }) .catch(() => { /* the stream will cover it */ }); connect(); + offViewed = onViewedType(render); render(); }, stop() { + offViewed?.(); + offViewed = null; stream?.close(); stream = null; root.innerHTML = ''; diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index 1b17e5b..d9b8ed9 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -1,8 +1,8 @@ { "id": "pco-plan", "name": "PCO Plan", - "version": "1.9.0", - "proddash": ">=1.4.0", + "version": "1.10.0", + "proddash": ">=1.12.0", "description": "Order of service, team assignments and the song key from Planning Center Services", "provides": [ "timers" @@ -46,11 +46,13 @@ "default": "", "group": "Planning Center credentials" }, - "serviceTypeId": { - "type": "select", - "label": "Service type", - "default": "", - "optionsRoute": "/service-types" + "serviceTypes": { + "type": "multiselect", + "label": "Service types", + "orderable": true, + "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": { "type": "select", @@ -63,7 +65,8 @@ } ], "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": { "type": "number", diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js index 89ef471..3c8d4c4 100644 --- a/modules/pco-plan/server.js +++ b/modules/pco-plan/server.js @@ -73,6 +73,7 @@ const path = require('path'); const { placeDocument, messageItemOf, playlistMirrorsPlan, similarity, MATCH_THRESHOLD } = require('./matching'); const { MINUTE, + DEFAULT_SERVICE_LENGTH_MS, serviceTimes, serviceWindows, selectService, @@ -111,6 +112,12 @@ const SEEN_DOCUMENTS_MAX = 200; * plan changes, and otherwise no more often than this. */ 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. */ const SERVICE_TEAMS_TTL_MS = 10 * MINUTE; /** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */ @@ -147,6 +154,14 @@ function clearedState() { items: [], teams: [], // who is scheduled, by team (the Teams tile) 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(), service: clearedService(), timing: null, // the admin's service-timing choices (no secrets) @@ -610,7 +625,20 @@ module.exports = { const streams = new Set(); // /stream clients (tiles) const timerStreams = new Set(); // /timers/stream clients (the Timers module) 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 pollMs = Math.max(MIN_POLL_S, Number(config.pollSeconds) || 30) * 1000; const follow = config.followProPresenter !== false; @@ -647,8 +675,9 @@ module.exports = { let state = clearedState(); state.configured = pco.configured; - state.serviceTypeSet = Boolean(serviceTypeId); - state.serviceType = { id: serviceTypeId, name: '' }; + state.serviceTypeSet = Boolean(serviceTypes.length); + state.serviceType = { id: activeTypeId, name: '' }; + state.serviceTypes = serviceTypes.slice(); state.live.following = follow; state.timing = { ...timing }; @@ -704,8 +733,37 @@ module.exports = { /* — plan reading — */ - function planPath(planId) { - return `${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(planId)}`; + function planPath(planId, stId = activeTypeId) { + 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: "/". + * 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. */ @@ -740,8 +798,61 @@ module.exports = { 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 || []) .map((t) => ({ id: String(t.id), @@ -754,8 +865,8 @@ module.exports = { .sort((x, y) => x.startsAt - y.startsAt); } - async function readItems(planId) { - const target = `${planPath(planId)}/items`; + async function readItems(planId, stId = activeTypeId) { + const target = `${planPath(planId, stId)}/items`; try { return shapeItems(await pco.getAll(target, { include: 'song,arrangement,item_notes' })); } catch (err) { @@ -775,18 +886,21 @@ module.exports = { * service type, not during a service. A failed read is not fatal — the * 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; 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) => ({ id: String(t.id), name: String(t.attributes?.name || '').trim() || `Team ${t.id}`, sequence: Number(t.attributes?.sequence) || i + 1, })); serviceTeams = { at: Date.now(), list }; + serviceTeamsByType.set(stId, serviceTeams); } catch (err) { log(`service type team list read failed: ${err?.message || err}`); 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. */ - let teamPhotos = new Map(); + const teamPhotos = new Map(); let teamsReadAt = 0; /** @@ -804,8 +918,8 @@ module.exports = { * them, exactly as the items read does: a tile that can name people * without photos is far better than a tile that shows nothing. */ - async function readTeams(planId) { - const target = `${planPath(planId)}/team_members`; + async function readTeams(planId, stId = activeTypeId) { + const target = `${planPath(planId, stId)}/team_members`; let body; try { body = await pco.getAll(target, { include: 'team,person' }); @@ -817,16 +931,16 @@ module.exports = { throw err; } } - const teams = shapeTeams(body, await readServiceTeams()); - const photos = new Map(); + const teams = shapeTeams(body, await readServiceTeams(stId)); const included = new Map(); for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec); for (const pp of body.data || []) { const personRef = pp.relationships?.person?.data; 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(); 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. */ function teamOptions() { return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) }; @@ -861,23 +1026,22 @@ module.exports = { async function readPlan() { - if (!pco.configured || !serviceTypeId) return; + if (!pco.configured || !serviceTypes.length) return; let planRec = null; - if (pinnedPlanId) { - planRec = (await pco.get(planPath(pinnedPlanId)))?.data || null; + const pin = parsePin(pinnedPlanId); + if (pin && pin.planId && pin.stId) { + await useServiceType(pin.stId); + planRec = (await pco.get(planPath(pin.planId, pin.stId)))?.data || null; } else { - const chosen = await choosePlanId(serviceTypeId); - // 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 immediately. - planRec = chosen ? (await pco.get(planPath(chosen.id)))?.data || chosen : null; - } - if (!state.serviceType.name) { - try { - const st = await pco.get(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}`); - state.serviceType = { id: serviceTypeId, name: String(st?.data?.attributes?.name || '') }; - } catch (err) { - if (err.status === 404) throw new Error(`Service type ${serviceTypeId} does not exist — pick another in the setup page`); - throw err; + const chosen = await choosePlanAcross(); + if (chosen) { + await useServiceType(chosen.stId); + // 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. + planRec = (await pco.get(planPath(chosen.rec.id, chosen.stId)))?.data || chosen.rec; + } else { + // Nothing anywhere: still name a service type so the tile can say whose. + await useServiceType(serviceTypes[0]); } } if (!planRec) { @@ -890,10 +1054,11 @@ module.exports = { state.lastError = ''; state.lastFetched = Date.now(); syncServiceSafe(Date.now(), null); + await readOtherTypes(true); return; } 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; if (planChanged) { 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 // from the plan's service times — decided in one place. 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. */ @@ -943,11 +1110,11 @@ module.exports = { * list — the gear window then says there is nothing to choose from yet. */ 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 (!noteCategoriesInFlight) { 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 options = (body.data || []) .filter((c) => !c?.attributes?.deleted_at) @@ -1713,11 +1880,11 @@ module.exports = { if (!pco.configured) { state.lastError = 'No Planning Center credentials — add a Client ID + Secret (or OAuth token) in /admin'; log(state.lastError); - } else if (!serviceTypeId) { + } else if (!serviceTypes.length) { state.lastError = 'No service type selected — choose one under PCO Plan in /admin'; log(state.lastError); } 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); } log( @@ -1777,6 +1944,7 @@ module.exports = { }, listPlans, pickPlan, + typeName, timersSnapshot, documentOptions, noteCategoryOptions, @@ -1805,11 +1973,11 @@ module.exports = { }, health() { 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) { 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 service = win ? ` · ${serviceLabel(win)} ${svc.phase}` : ''; const live = wantFeed ? (state.live.available ? '' : ' · ProPresenter feed unavailable') : ''; @@ -1986,8 +2154,15 @@ module.exports = { } return names.join(' › '); }; + const live = current.getState().serviceType?.id || ''; 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)); json(res, 200, { options }); } catch (err) { @@ -1997,40 +2172,46 @@ module.exports = { }, /** - * Options for the admin page's "Plan" select, for the service type the - * admin currently has picked (?serviceTypeId=…): the upcoming and recent - * plans, with the one Automatic would choose marked. + * Options for the admin page's "Plan" select, across every service type + * ticked above (?serviceTypes=a,b,c), grouped by which one each belongs + * to. A value is "/", because a plan id alone does + * not say whose it is once there is more than one list. */ 'GET /plans': async (req, res) => { if (!current) return json(res, 503, { error: 'Module not running.' }); const { pco } = current; 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' }; - if (!pco.configured || !stId) return json(res, 200, { options: [auto] }); - try { - const plans = await current.listPlans(stId, { future: 8, past: 6 }); - const picked = current.pickPlan(plans); - const now = Date.now(); - const options = [auto]; - // Upcoming first (soonest at the top), then the recent ones. - const ordered = [ - ...plans.filter((p) => p.sortDate >= now - 12 * 3600000), - ...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(), - ]; - for (const { rec, sortDate } of ordered) { - const a = rec.attributes || {}; - const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())]; - if (a.series_title) bits.push(String(a.series_title)); - if (a.title) bits.push(String(a.title)); - let label = bits.join(' — '); - if (picked && rec.id === picked.id) label += ' (Automatic picks this)'; - options.push({ value: String(rec.id), label, group: sortDate >= now - 12 * 3600000 ? 'Upcoming' : 'Recent' }); + if (!pco.configured || !asked.length) return json(res, 200, { options: [auto] }); + const options = [auto]; + let failure = ''; + for (const stId of asked) { + try { + const [name, plans] = await Promise.all([ + current.typeName(stId), + current.listPlans(stId, { future: 8, past: 6 }), + ]); + const picked = current.pickPlan(plans); + const now = Date.now(); + const ordered = [ + ...plans.filter((p) => p.sortDate >= now - 12 * 3600000), + ...plans.filter((p) => p.sortDate < now - 12 * 3600000).reverse(), + ]; + for (const { rec, sortDate } of ordered) { + const a = rec.attributes || {}; + const bits = [String(a.dates || a.short_dates || new Date(sortDate).toLocaleDateString())]; + if (a.series_title) bits.push(String(a.series_title)); + if (a.title) bits.push(String(a.title)); + let label = bits.join(' — '); + if (picked && rec.id === picked.id) label += ' (this one, on Automatic)'; + options.push({ value: `${stId}/${rec.id}`, label, group: name }); + } + } catch (err) { + failure = err?.message || String(err); } - json(res, 200, { options }); - } catch (err) { - json(res, err?.status === 401 ? 401 : 502, { options: [auto], error: err?.message || String(err) }); } + json(res, 200, failure && options.length === 1 ? { options, error: failure } : { options }); }, }; }, diff --git a/modules/pco-plan/style.css b/modules/pco-plan/style.css index 035ee48..537cf08 100644 --- a/modules/pco-plan/style.css +++ b/modules/pco-plan/style.css @@ -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-name, [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); +} diff --git a/modules/pco-plan/teams-client.js b/modules/pco-plan/teams-client.js index a1fa702..f0416a4 100644 --- a/modules/pco-plan/teams-client.js +++ b/modules/pco-plan/teams-client.js @@ -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 = `
@@ -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 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 = ''; diff --git a/modules/pco-plan/viewed-type.js b/modules/pco-plan/viewed-type.js new file mode 100644 index 0000000..11be1fb --- /dev/null +++ b/modules/pco-plan/viewed-type.js @@ -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 = ''; + +/** + * 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 || '')); + }, + }; +} diff --git a/package.json b/package.json index 817bc41..2dd686c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proddash", - "version": "1.11.0", + "version": "1.12.0", "private": true, "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "license": "UNLICENSED", diff --git a/public/admin/admin.css b/public/admin/admin.css index 9796d91..c13f7ff 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -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; } diff --git a/public/admin/admin.js b/public/admin/admin.js index ee3b594..9f70abd 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -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(); } diff --git a/tools/pco-mock.js b/tools/pco-mock.js index 73b8717..26487c7 100644 --- a/tools/pco-mock.js +++ b/tools/pco-mock.js @@ -8,6 +8,9 @@ * Serves JSON:API shapes matching api.planningcenteronline.com/services/v2: * GET /services/v2/folders nested folders (parent rel) * 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 * parent folder relationship * GET /services/v2/service_types/{id} @@ -49,6 +52,10 @@ const http = require('http'); 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(); /* ── data ───────────────────────────────────────────────────────────── */ @@ -68,6 +75,18 @@ const SERVICE_TYPES = [ { 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) { return new Date(ms).toISOString(); }