Merge PCO Plan: note categories as switches, top-bar variable chips, no text-size field
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,9 @@
|
||||
ProPresenter module's live item; this tile only renders the plan it is
|
||||
streamed. What is shown is this tile's own choice (gear menu →
|
||||
moduleApi.instanceSettings: show/hide checklist, top-bar template,
|
||||
colors, text size); /admin only holds the connection settings. */
|
||||
colors, item-note categories); the text size is the title bar's A− / A+
|
||||
(saved with the tile, 14 px until someone presses one); /admin only holds
|
||||
the connection settings. */
|
||||
|
||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||
|
||||
@@ -121,10 +123,19 @@ export default function create({ root, moduleApi }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Which note categories this tile shows. Unset (never chosen in the gear
|
||||
* window) means every category, including ones Planning Center gains
|
||||
* later; an array is exactly those, by name. A layout saved before the
|
||||
* categories were a list holds a comma-separated string — read as that
|
||||
* list, blank as unset.
|
||||
*/
|
||||
function noteFilter() {
|
||||
const raw = String(cfg().noteCategories || '');
|
||||
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
|
||||
const raw = cfg().noteCategories;
|
||||
const names = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
|
||||
const wanted = new Set(names.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
|
||||
if (!Array.isArray(raw) && !wanted.size) return () => true;
|
||||
return (note) => wanted.has(String(note.category || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
/* ── status dot ─────────────────────────────────────────────────── */
|
||||
|
||||
@@ -121,6 +121,9 @@
|
||||
"collapsed": false,
|
||||
"columns": 2
|
||||
},
|
||||
"Item notes": {
|
||||
"toggle": "showNotes"
|
||||
},
|
||||
"Show / hide": {
|
||||
"collapsed": true,
|
||||
"columns": 3,
|
||||
@@ -128,25 +131,38 @@
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
|
||||
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
||||
"noteCategories": {
|
||||
"type": "string",
|
||||
"label": "Item note categories to show (comma-separated, blank = all)",
|
||||
"default": ""
|
||||
},
|
||||
"headerTemplate": {
|
||||
"type": "string",
|
||||
"type": "template",
|
||||
"label": "Top bar text",
|
||||
"default": "{series} • {part} | {serviceType} • {date}",
|
||||
"group": "Top bar",
|
||||
"help": "Placeholders: {series} series name · {part} plan title / part number · {serviceType} · {date} · {shortDate} · {time} service start time · {length} planned length · {items} item count. Use • between items (an item whose placeholders are empty or hidden is dropped) and | to start a second line."
|
||||
"variables": [
|
||||
{ "value": "{series}", "label": "Series" },
|
||||
{ "value": "{part}", "label": "Part / plan title" },
|
||||
{ "value": "{serviceType}", "label": "Service type" },
|
||||
{ "value": "{date}", "label": "Date" },
|
||||
{ "value": "{shortDate}", "label": "Short date" },
|
||||
{ "value": "{time}", "label": "Service time" },
|
||||
{ "value": "{length}", "label": "Planned length" },
|
||||
{ "value": "{items}", "label": "Item count" }
|
||||
],
|
||||
"help": "Use • between items and | to start a second line."
|
||||
},
|
||||
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
|
||||
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
|
||||
"currentColor": { "type": "color", "label": "Current item", "default": "#2ee59a", "group": "Colors" },
|
||||
"headerGradient": { "type": "boolean", "label": "Fade header color to the right", "default": true, "group": "Colors" },
|
||||
"tintByType": { "type": "boolean", "label": "Tint items by type (song / media / other)", "default": false, "group": "Colors" },
|
||||
"showNotes": { "type": "switch", "label": "Item notes", "default": true, "group": "Item notes" },
|
||||
"noteCategories": {
|
||||
"type": "multiselect",
|
||||
"label": "Categories",
|
||||
"group": "Item notes",
|
||||
"optionsRoute": "/note-categories",
|
||||
"columns": 3,
|
||||
"allByDefault": true
|
||||
},
|
||||
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
|
||||
"showSeries": { "type": "boolean", "label": "Series name", "default": true, "group": "Show / hide" },
|
||||
"showPart": { "type": "boolean", "label": "Part # / plan title", "default": true, "group": "Show / hide" },
|
||||
@@ -165,7 +181,6 @@
|
||||
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
|
||||
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "default": false, "group": "Show / hide" },
|
||||
"showDescription": { "type": "boolean", "label": "Item description", "default": true, "group": "Show / hide" },
|
||||
"showNotes": { "type": "boolean", "label": "Item notes", "default": true, "group": "Show / hide" },
|
||||
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
|
||||
"showPostService": { "type": "boolean", "label": "Post-service items", "default": true, "group": "Show / hide" },
|
||||
"showNowIndicator": { "type": "boolean", "label": "Now indicator", "default": true, "group": "Show / hide" },
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* GET /plans?serviceTypeId=… → { options } for the admin "Plan" select
|
||||
* GET /documents → { options } for the admin start / end document selects
|
||||
* (documents ProPresenter has shown or lists — see below)
|
||||
* GET /note-categories → { options } for a tile's "Item notes" categories:
|
||||
* the service type's item note categories, by name
|
||||
* GET /timers → snapshot for the Timers module (guide: "Offering
|
||||
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
||||
*
|
||||
@@ -26,6 +28,7 @@
|
||||
* GET /service_types/{st}/plans/{id} (title, dates…)
|
||||
* GET …/plans/{id}/plan_times (service start times)
|
||||
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
||||
* GET /service_types/{st}/item_note_categories (note category names)
|
||||
*
|
||||
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||
* this module reads the propresenter-now-next module's own SSE feed over
|
||||
@@ -78,6 +81,8 @@ const NOW_NEXT_RETRY_MS = 3000;
|
||||
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||
/** How many distinct documents seen live are remembered for the admin selects. */
|
||||
const SEEN_DOCUMENTS_MAX = 200;
|
||||
/** How long a service type's item note categories are believed before Planning Center is asked again. */
|
||||
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
|
||||
/**
|
||||
* Service timer tone (the Timers module colours by it): running behind the
|
||||
* plan — completed items over their planned length, plus the current item's
|
||||
@@ -591,6 +596,44 @@ module.exports = {
|
||||
syncServiceSafe(Date.now(), null);
|
||||
}
|
||||
|
||||
/* — item note categories (a tile's "Item notes" → Categories switches) — */
|
||||
|
||||
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
|
||||
let noteCategoriesInFlight = null;
|
||||
|
||||
/**
|
||||
* The service type's item note categories, by name — notes are matched
|
||||
* to categories by name, so the names are the values. Believed for
|
||||
* NOTE_CATEGORIES_TTL_MS, read once however many tiles ask at the same
|
||||
* moment; a failed re-read keeps serving the last good list. A module
|
||||
* that cannot ask (no credentials, no service type) answers an empty
|
||||
* list — the gear window then says there is nothing to choose from yet.
|
||||
*/
|
||||
async function noteCategoryOptions() {
|
||||
if (!pco.configured || !serviceTypeId) 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 seen = new Set();
|
||||
const options = (body.data || [])
|
||||
.filter((c) => !c?.attributes?.deleted_at)
|
||||
.map((c) => ({ name: String(c.attributes?.name || '').trim(), sequence: Number(c.attributes?.sequence) || 0 }))
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.filter((c) => c.name && !seen.has(c.name.toLowerCase()) && seen.add(c.name.toLowerCase()))
|
||||
.map((c) => ({ value: c.name, label: c.name }));
|
||||
noteCategories = { at: Date.now(), options };
|
||||
return options;
|
||||
})().finally(() => { noteCategoriesInFlight = null; });
|
||||
}
|
||||
try {
|
||||
return await noteCategoriesInFlight;
|
||||
} catch (err) {
|
||||
log(`item note categories read failed: ${err?.message || err}`);
|
||||
return noteCategories.options;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePoll(ms) {
|
||||
if (stopped) return;
|
||||
clearTimeout(pollTimer);
|
||||
@@ -1214,6 +1257,7 @@ module.exports = {
|
||||
pickPlan,
|
||||
timersSnapshot,
|
||||
documentOptions,
|
||||
noteCategoryOptions,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1312,6 +1356,23 @@ module.exports = {
|
||||
json(res, 200, current.documentOptions());
|
||||
},
|
||||
|
||||
/**
|
||||
* Options for a tile's "Item notes" → Categories switches: the
|
||||
* configured service type's item note categories, by name. Always a
|
||||
* 200 with a list — empty while the module is unconfigured, not
|
||||
* running or cannot reach Planning Center — because the gear window
|
||||
* has nothing better to do with an error than show an empty list.
|
||||
*/
|
||||
'GET /note-categories': async (req, res) => {
|
||||
let options = [];
|
||||
try {
|
||||
if (current) options = await current.noteCategoryOptions();
|
||||
} catch (err) {
|
||||
log(`note categories: ${err?.message || err}`);
|
||||
}
|
||||
json(res, 200, { options });
|
||||
},
|
||||
|
||||
/**
|
||||
* Options for the admin page's "Service type" select: every service
|
||||
* type, grouped by its Planning Center folder path — so the folders
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
* GET /services/v2/service_types every service type, with its
|
||||
* parent folder relationship
|
||||
* GET /services/v2/service_types/{id}
|
||||
* GET /services/v2/service_types/{id}/item_note_categories
|
||||
* the note categories the
|
||||
* items below use, plus one
|
||||
* ("Stage") no note uses yet
|
||||
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
|
||||
* per_page — today's plan,
|
||||
* next week's and last week's
|
||||
@@ -129,6 +133,22 @@ const ITEMS = [
|
||||
{ id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Item note categories, per service type in Planning Center: every category
|
||||
* the items above use, in the order the mock account lists them, plus one no
|
||||
* note uses yet — a tile that has never chosen shows it on as well.
|
||||
*/
|
||||
const NOTE_CATEGORIES = ['Audio', 'Video', 'Lighting', 'ProPresenter', 'Stage'];
|
||||
|
||||
function noteCategoryRecord(name, index, stId) {
|
||||
return {
|
||||
type: 'ItemNoteCategory',
|
||||
id: `${stId}-nc${index + 1}`,
|
||||
attributes: { name, sequence: index + 1, frequently_used: index < 2, deleted_at: null },
|
||||
relationships: { service_type: { data: { type: 'ServiceType', id: stId } } },
|
||||
};
|
||||
}
|
||||
|
||||
function itemsDocument(planId, include) {
|
||||
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||
const data = [];
|
||||
@@ -246,6 +266,9 @@ const server = http.createServer((req, res) => {
|
||||
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
||||
if (!st) return notFound(res);
|
||||
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
|
||||
if (parts[2] === 'item_note_categories' && parts.length === 3) {
|
||||
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
|
||||
}
|
||||
if (parts[2] !== 'plans') return notFound(res);
|
||||
const plans = plansFor(st.id);
|
||||
if (parts.length === 3) {
|
||||
|
||||
Reference in New Issue
Block a user