From 0c4828eb26f6d5c692094812a86080cfdd61d16a Mon Sep 17 00:00:00 2001 From: Waters Church Date: Mon, 14 Sep 2026 15:35:47 -0400 Subject: [PATCH] pco-plan: note categories as switches, top-bar variables as chips, no text-size field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item notes get their own gear group, after Colors, with the existing showNotes switch as the group's on/off in its title row. Its categories come from Planning Center (GET /note-categories → the service type's item_note_categories, by name, cached 10 min, an empty list rather than an error when unconfigured or unreachable) and render as switches, three across, all on until someone flips one; the tile reads an unset value as every category, an array as exactly those, and a layout's old comma-separated string as that list (blank = all). The top bar template is a `template` field: one chip per placeholder ({series} {part} {serviceType} {date} {shortDate} {time} {length} {items}) to drag or click into the text, with the help down to the joining rules. Rendering is unchanged. Text size leaves the gear window: the title bar's A− / A+ already size the text and save it with the tile; the client falls back to 14 px when nothing is saved. tools/pco-mock.js serves item_note_categories (the categories its notes use plus "Stage", which none does yet) so the route can be exercised. Co-Authored-By: Claude Fable 5.1 --- modules/pco-plan/client.js | 19 ++++++++--- modules/pco-plan/module.json | 33 +++++++++++++------ modules/pco-plan/server.js | 61 ++++++++++++++++++++++++++++++++++++ tools/pco-mock.js | 23 ++++++++++++++ 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/modules/pco-plan/client.js b/modules/pco-plan/client.js index 9e8cdf7..34f30e2 100644 --- a/modules/pco-plan/client.js +++ b/modules/pco-plan/client.js @@ -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 = ''; @@ -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 ─────────────────────────────────────────────────── */ diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index 9ab9699..7899921 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -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" }, diff --git a/modules/pco-plan/server.js b/modules/pco-plan/server.js index 77f4852..e1dd0b3 100644 --- a/modules/pco-plan/server.js +++ b/modules/pco-plan/server.js @@ -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 diff --git a/tools/pco-mock.js b/tools/pco-mock.js index 73da9c2..d793717 100644 --- a/tools/pco-mock.js +++ b/tools/pco-mock.js @@ -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) {