pco-plan: note categories as switches, top-bar variables as chips, no text-size field
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
7a44b25d4b
commit
0c4828eb26
@@ -4,7 +4,9 @@
|
|||||||
ProPresenter module's live item; this tile only renders the plan it is
|
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 →
|
streamed. What is shown is this tile's own choice (gear menu →
|
||||||
moduleApi.instanceSettings: show/hide checklist, top-bar template,
|
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>';
|
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() {
|
function noteFilter() {
|
||||||
const raw = String(cfg().noteCategories || '');
|
const raw = cfg().noteCategories;
|
||||||
const wanted = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
const names = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
|
||||||
return wanted.length ? (note) => wanted.includes(String(note.category || '').toLowerCase()) : () => true;
|
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 ─────────────────────────────────────────────────── */
|
/* ── status dot ─────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -121,6 +121,9 @@
|
|||||||
"collapsed": false,
|
"collapsed": false,
|
||||||
"columns": 2
|
"columns": 2
|
||||||
},
|
},
|
||||||
|
"Item notes": {
|
||||||
|
"toggle": "showNotes"
|
||||||
|
},
|
||||||
"Show / hide": {
|
"Show / hide": {
|
||||||
"collapsed": true,
|
"collapsed": true,
|
||||||
"columns": 3,
|
"columns": 3,
|
||||||
@@ -128,25 +131,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instanceSchema": {
|
"instanceSchema": {
|
||||||
"textSize": { "type": "number", "label": "Text size (px)", "default": 14 },
|
|
||||||
"autoScroll": { "type": "boolean", "label": "Keep the current item in view", "default": true },
|
"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": {
|
"headerTemplate": {
|
||||||
"type": "string",
|
"type": "template",
|
||||||
"label": "Top bar text",
|
"label": "Top bar text",
|
||||||
"default": "{series} • {part} | {serviceType} • {date}",
|
"default": "{series} • {part} | {serviceType} • {date}",
|
||||||
"group": "Top bar",
|
"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" },
|
"keyColor": { "type": "color", "label": "Song key", "default": "#4ea1ff", "group": "Colors" },
|
||||||
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
|
"headerColor": { "type": "color", "label": "Section headers", "default": "#2ee59a", "group": "Colors" },
|
||||||
"currentColor": { "type": "color", "label": "Current item", "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" },
|
"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" },
|
"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" },
|
"showTopBar": { "type": "boolean", "label": "Top bar", "default": true, "group": "Show / hide" },
|
||||||
"showSeries": { "type": "boolean", "label": "Series name", "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" },
|
"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" },
|
"showArrangement": { "type": "boolean", "label": "Song arrangement", "default": false, "group": "Show / hide" },
|
||||||
"showSongAuthor": { "type": "boolean", "label": "Song author / CCLI", "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" },
|
"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" },
|
"showPreService": { "type": "boolean", "label": "Pre-service items", "default": true, "group": "Show / hide" },
|
||||||
"showPostService": { "type": "boolean", "label": "Post-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" },
|
"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 /plans?serviceTypeId=… → { options } for the admin "Plan" select
|
||||||
* GET /documents → { options } for the admin start / end document selects
|
* GET /documents → { options } for the admin start / end document selects
|
||||||
* (documents ProPresenter has shown or lists — see below)
|
* (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 → snapshot for the Timers module (guide: "Offering
|
||||||
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
||||||
*
|
*
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
* GET /service_types/{st}/plans/{id} (title, dates…)
|
* GET /service_types/{st}/plans/{id} (title, dates…)
|
||||||
* GET …/plans/{id}/plan_times (service start times)
|
* GET …/plans/{id}/plan_times (service start times)
|
||||||
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
* 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,
|
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||||
* this module reads the propresenter-now-next module's own SSE feed over
|
* 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;
|
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||||
/** How many distinct documents seen live are remembered for the admin selects. */
|
/** How many distinct documents seen live are remembered for the admin selects. */
|
||||||
const SEEN_DOCUMENTS_MAX = 200;
|
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
|
* Service timer tone (the Timers module colours by it): running behind the
|
||||||
* plan — completed items over their planned length, plus the current item's
|
* plan — completed items over their planned length, plus the current item's
|
||||||
@@ -591,6 +596,44 @@ module.exports = {
|
|||||||
syncServiceSafe(Date.now(), null);
|
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) {
|
function schedulePoll(ms) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
@@ -1214,6 +1257,7 @@ module.exports = {
|
|||||||
pickPlan,
|
pickPlan,
|
||||||
timersSnapshot,
|
timersSnapshot,
|
||||||
documentOptions,
|
documentOptions,
|
||||||
|
noteCategoryOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1312,6 +1356,23 @@ module.exports = {
|
|||||||
json(res, 200, current.documentOptions());
|
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
|
* Options for the admin page's "Service type" select: every service
|
||||||
* type, grouped by its Planning Center folder path — so the folders
|
* 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
|
* GET /services/v2/service_types every service type, with its
|
||||||
* parent folder relationship
|
* parent folder relationship
|
||||||
* GET /services/v2/service_types/{id}
|
* 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,
|
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
|
||||||
* per_page — today's plan,
|
* per_page — today's plan,
|
||||||
* next week's and last week's
|
* 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']] },
|
{ 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) {
|
function itemsDocument(planId, include) {
|
||||||
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
||||||
const data = [];
|
const data = [];
|
||||||
@@ -246,6 +266,9 @@ const server = http.createServer((req, res) => {
|
|||||||
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
||||||
if (!st) return notFound(res);
|
if (!st) return notFound(res);
|
||||||
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
|
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);
|
if (parts[2] !== 'plans') return notFound(res);
|
||||||
const plans = plansFor(st.id);
|
const plans = plansFor(st.id);
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
|
|||||||
Reference in New Issue
Block a user