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
+249
-68
@@ -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: "<serviceType>/<plan>".
|
||||
* 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 "<serviceType>/<plan>", 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 });
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user